add better keyboard nav and fix sonar lint

This commit is contained in:
Eduard Gert
2026-06-18 12:24:11 +02:00
parent 79e7dce47e
commit 3175b880e4
15 changed files with 473 additions and 312 deletions

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/cn";
@@ -21,6 +21,7 @@ type CopyToClipboardProps = {
variant?: CopyToClipboardVariant;
"aria-label"?: string;
tabIndex?: number;
onKeyDown?: (e: KeyboardEvent<HTMLButtonElement>) => void;
};
export const CopyToClipboard = ({
@@ -34,6 +35,7 @@ export const CopyToClipboard = ({
variant = "default",
"aria-label": ariaLabel,
tabIndex = 0,
onKeyDown,
}: CopyToClipboardProps) => {
const { t } = useTranslation();
const wrapperRef = useRef<HTMLButtonElement>(null);
@@ -69,6 +71,7 @@ export const CopyToClipboard = ({
type="button"
ref={wrapperRef}
onClick={handleClick}
onKeyDown={onKeyDown}
tabIndex={tabIndex}
aria-label={resolvedLabel}
aria-live="polite"

View File

@@ -13,12 +13,13 @@ export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(func
ref,
) {
return (
<div
role="dialog"
aria-modal="true"
<dialog
open
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={"wails-draggable select-none flex flex-col items-center"}
className={
"wails-draggable select-none flex flex-col items-center static bg-transparent text-inherit p-0 m-0 max-w-none max-h-none border-0 w-full"
}
>
<div
ref={ref}
@@ -29,6 +30,6 @@ export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(func
>
{children}
</div>
</div>
</dialog>
);
});

View File

@@ -33,8 +33,6 @@ export default function FancyToggleSwitch({
}: Readonly<Props>) {
const switchId = React.useId();
const descriptionId = React.useId();
const childrenRef = React.useRef<HTMLDivElement>(null);
const switchRef = React.useRef<HTMLButtonElement>(null);
if (loading) {
const shimmer =
@@ -68,21 +66,8 @@ export default function FancyToggleSwitch({
);
}
const fromChildren = (target: EventTarget | null) =>
target instanceof Node && childrenRef.current?.contains(target);
const handleClick = (event: React.MouseEvent) => {
if (disabled || fromChildren(event.target)) return;
const target = event.target as HTMLElement;
// Let the switch own its own click so focus + state stay together.
if (target.closest("button,input,a,[role=switch]")) return;
switchRef.current?.click();
switchRef.current?.focus();
};
return (
<div
onClick={handleClick}
{...(disabled ? { inert: "" } : {})}
className={cn(
"cursor-default transition-all duration-300 relative z-[1]",
@@ -93,10 +78,8 @@ export default function FancyToggleSwitch({
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label as="div" className={labelClassName}>
<label htmlFor={switchId} className={"cursor-default"}>
{label}
</label>
<Label htmlFor={switchId} className={labelClassName}>
{label}
</Label>
<HelpText margin={false}>
<span id={descriptionId}>{helpText}</span>
@@ -104,7 +87,6 @@ export default function FancyToggleSwitch({
</div>
<div className={"mt-2 pr-1"}>
<ToggleSwitch
ref={switchRef}
id={switchId}
checked={value}
onCheckedChange={onChange}
@@ -113,11 +95,7 @@ export default function FancyToggleSwitch({
/>
</div>
</div>
{children && value ? (
<div className="mt-4" ref={childrenRef}>
{children}
</div>
) : null}
{children && value ? <div className="mt-4">{children}</div> : null}
</div>
);
}

View File

@@ -25,26 +25,26 @@ export const usePeerDetail = (): PeerDetailContextValue => {
};
export const PeerDetailProvider = ({ children }: { children: ReactNode }) => {
const [selected, setSelectedState] = useState<PeerStatus | null>(null);
const [selected, setSelected] = useState<PeerStatus | null>(null);
const openerRef = useRef<HTMLElement | null>(null);
const setSelected = useCallback((p: PeerStatus | null) => {
const select = useCallback((p: PeerStatus | null) => {
if (p) {
const active = document.activeElement;
openerRef.current = active instanceof HTMLElement ? active : null;
} else {
const opener = openerRef.current;
openerRef.current = null;
if (opener && opener.isConnected) {
if (opener?.isConnected) {
queueMicrotask(() => opener.focus());
}
}
setSelectedState(p);
setSelected(p);
}, []);
const value = useMemo<PeerDetailContextValue>(
() => ({ selected, setSelected }),
[selected, setSelected],
() => ({ selected, setSelected: select }),
[selected, select],
);
return <PeerDetailContext.Provider value={value}>{children}</PeerDetailContext.Provider>;
};

View File

@@ -8,20 +8,71 @@ export const EVENT_TRIGGER_LOGIN = "trigger-login";
let connectionInFlight = false;
export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise<void> {
if (connectionInFlight) {
onSettled?.();
return;
type SsoState = {
cancelled: boolean;
offCancel?: () => void;
offSignal?: () => void;
};
async function openBrowserLoginUri(uri: string): Promise<void> {
try {
await WindowManager.OpenBrowserLogin(uri);
} catch (e) {
console.error(e);
}
if (signal?.aborted) {
}
function buildSsoCancelPromise(state: SsoState, signal?: AbortSignal): Promise<void> {
return new Promise<void>((resolve) => {
state.offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => {
state.cancelled = true;
resolve();
});
if (!signal) return;
const onAbort = () => {
state.cancelled = true;
resolve();
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener("abort", onAbort);
state.offSignal = () => signal.removeEventListener("abort", onAbort);
});
}
async function runSsoLogin(
result: { verificationUri: string; verificationUriComplete: string; userCode: string },
state: SsoState,
signal?: AbortSignal,
): Promise<void> {
const uri = result.verificationUriComplete || result.verificationUri;
if (uri) await openBrowserLoginUri(uri);
const cancelPromise = buildSsoCancelPromise(state, signal);
const waitPromise = Connection.WaitSSOLogin({ userCode: result.userCode, hostname: "" });
try {
await Promise.race([waitPromise, cancelPromise]);
} finally {
WindowManager.CloseBrowserLogin().catch(console.error);
}
if (state.cancelled) {
waitPromise.cancel?.();
waitPromise.catch(() => {});
}
}
export async function startConnection(onSettled?: () => void, signal?: AbortSignal): Promise<void> {
if (connectionInFlight || signal?.aborted) {
onSettled?.();
return;
}
connectionInFlight = true;
let cancelled = false;
let offCancel: (() => void) | undefined;
let offSignal: (() => void) | undefined;
const state: SsoState = { cancelled: false };
let connectError: unknown;
try {
@@ -35,65 +86,23 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign
hint: "",
});
if (signal?.aborted) cancelled = true;
if (signal?.aborted) state.cancelled = true;
if (!cancelled && result.needsSsoLogin) {
const uri = result.verificationUriComplete || result.verificationUri;
if (uri) {
try {
await WindowManager.OpenBrowserLogin(uri);
} catch (e) {
console.error(e);
}
}
const cancelPromise = new Promise<void>((resolve) => {
offCancel = Events.On(EVENT_BROWSER_LOGIN_CANCEL, () => {
cancelled = true;
resolve();
});
if (signal) {
const onAbort = () => {
cancelled = true;
resolve();
};
if (signal.aborted) {
onAbort();
} else {
signal.addEventListener("abort", onAbort);
offSignal = () => signal.removeEventListener("abort", onAbort);
}
}
});
const waitPromise = Connection.WaitSSOLogin({
userCode: result.userCode,
hostname: "",
});
try {
await Promise.race([waitPromise, cancelPromise]);
} finally {
WindowManager.CloseBrowserLogin().catch(console.error);
}
if (cancelled) {
waitPromise.cancel?.();
waitPromise.catch(() => {});
}
if (!state.cancelled && result.needsSsoLogin) {
await runSsoLogin(result, state, signal);
}
if (!cancelled && signal?.aborted) cancelled = true;
if (!state.cancelled && signal?.aborted) state.cancelled = true;
if (!cancelled) {
if (!state.cancelled) {
await Connection.Up({ profileName: "", username: "" });
}
} catch (e) {
WindowManager.CloseBrowserLogin().catch(console.error);
if (!cancelled) connectError = e;
if (!state.cancelled) connectError = e;
} finally {
offCancel?.();
offSignal?.();
state.offCancel?.();
state.offSignal?.();
connectionInFlight = false;
onSettled?.();
}
@@ -106,7 +115,7 @@ export async function startConnection(onSettled?: () => void, signal?: AbortSign
return;
}
if (cancelled && signal) {
if (state.cancelled && signal) {
throw new DOMException("aborted", "AbortError");
}
}

View File

@@ -21,18 +21,32 @@ let inForward = false;
let windowStart = 0;
let windowCount = 0;
function describeCause(rawCause: unknown): string {
if (rawCause instanceof Error) return `${rawCause.name}: ${rawCause.message}`;
if (typeof rawCause === "object" && rawCause !== null) {
try {
return JSON.stringify(rawCause);
} catch {
// Circular ref — fall through to a tag instead of "[object Object]".
return `<${rawCause.constructor?.name ?? "object"}>`;
}
}
return String(rawCause);
}
function formatCause(rawCause: unknown): string {
if (rawCause === undefined) return "";
return `\ncaused by ${describeCause(rawCause)}`;
}
// WebKit (macOS WKWebView) omits the "Name: message" header from Error.stack,
// so a bare stack hides the real cause. Prepend name+message, then the stack.
function formatError(e: Error): string {
const head = `${e.name}: ${e.message}`;
const rawCause = (e as { cause?: unknown }).cause;
const cause =
rawCause instanceof Error
? `\ncaused by ${rawCause.name}: ${rawCause.message}`
: rawCause !== undefined
? `\ncaused by ${String(rawCause)}`
: "";
return e.stack && !e.stack.startsWith(head) ? `${head}${cause}\n${e.stack}` : `${head}${cause}`;
const cause = formatCause((e as { cause?: unknown }).cause);
if (!e.stack) return `${head}${cause}`;
if (e.stack.startsWith(head)) return `${head}${cause}`;
return `${head}${cause}\n${e.stack}`;
}
function format(args: unknown[]): string {
@@ -49,13 +63,34 @@ function format(args: unknown[]): string {
.join(" ");
}
function parseStackLine(line: string): string {
// Find the file:line:col tail at the end of the path.
const colonCol = line.lastIndexOf(":");
if (colonCol <= 0) return "";
const colonLine = line.lastIndexOf(":", colonCol - 1);
if (colonLine <= 0) return "";
const col = line.slice(colonCol + 1);
const lineNo = line.slice(colonLine + 1, colonCol);
if (!/^\d+$/.test(col) || !/^\d+$/.test(lineNo)) return "";
const before = line.slice(0, colonLine);
const sep = Math.max(
before.lastIndexOf("/"),
before.lastIndexOf("\\"),
before.lastIndexOf("("),
before.lastIndexOf(" "),
);
const file = before.slice(sep + 1);
if (!file.includes(".")) return "";
return `${file}:${lineNo}`;
}
function callerSource(): string {
const stack = new Error().stack;
if (!stack) return "";
for (const line of stack.split("\n").slice(1)) {
if (line.includes("/logs.ts")) continue;
const m = /([^/\\() ]+\.[a-z]+):(\d+):\d+/i.exec(line);
if (m) return `${m[1]}:${m[2]}`;
const parsed = parseStackLine(line);
if (parsed) return parsed;
}
return "";
}

View File

@@ -11,7 +11,7 @@ export function welcome() {
NetBird — The Only Secure Access Platform You'll Ever Need.
WEBSITE: https://netbird.io/
WE'RE HIRING: https://careers.netbird.io/
WE'RE HIRING: https://netbird.io/careers
OPEN SOURCE: https://github.com/netbirdio/netbird
`;

View File

@@ -1,11 +1,10 @@
import { ComponentType, KeyboardEvent, useRef } from "react";
import { ComponentType, KeyboardEvent, useEffect, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Layers3Icon, LucideProps, MonitorSmartphoneIcon } from "lucide-react";
import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { useEffect } from "react";
type TabEntry = {
value: NavSection;

View File

@@ -5,6 +5,7 @@ import {
useRef,
useState,
type ComponentType,
type ReactNode,
} from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
@@ -242,7 +243,6 @@ type NetworksListProps = {
const NetworksHeader = () => <div className={"h-2"} />;
const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
const { t } = useTranslation();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
@@ -265,7 +265,7 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
}
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>, index: number) => {
const handleRowKeyDown = (e: KeyboardEvent<Element>, index: number) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
@@ -286,69 +286,106 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
}
};
const setRowRef = (id: string, el: HTMLButtonElement | null) => {
if (el) rowRefs.current.set(id, el);
else rowRefs.current.delete(id);
};
const ctx = useMemo<NetworkRowContext>(
() => ({ onKeyDown: handleRowKeyDown, onToggle, setRowRef }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, onToggle],
);
return (
<Virtuoso
<Virtuoso<Network, NetworkRowContext>
ref={virtuosoRef}
data={data}
customScrollParent={scrollParent}
increaseViewportBy={400}
computeItemKey={(_, n) => n.id}
components={{ Header: NetworksHeader }}
itemContent={(index, n) => (
<div
role="listitem"
onKeyDown={(e) => handleRowKeyDown(e, index)}
className={cn(
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable",
)}
>
<button
type={"button"}
tabIndex={0}
ref={(el) => {
if (el) rowRefs.current.set(n.id, el);
else rowRefs.current.delete(n.id);
}}
aria-label={t("networks.row.toggle", { name: n.id })}
aria-pressed={n.selected}
onClick={() => onToggle(n.id, n.selected)}
className={cn(
"absolute inset-0 cursor-pointer outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
/>
<ResourceIconBadge type={resourceTypeOf(n)} />
<div
className={
"min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none"
}
>
<div>
<CopyToClipboard message={n.id}>
<TruncatedText
text={n.id}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}
/>
</CopyToClipboard>
</div>
<Subtitle network={n} />
</div>
<div
aria-hidden="true"
className={"shrink-0 self-center relative pointer-events-none"}
>
<NetworkToggle checked={n.selected} />
</div>
</div>
)}
context={ctx}
itemContent={renderNetworkRow}
/>
);
};
type NetworkRowContext = {
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onToggle: (id: string, selected: boolean) => void;
setRowRef: (id: string, el: HTMLButtonElement | null) => void;
};
const renderNetworkRow = (index: number, n: Network, ctx: NetworkRowContext): ReactNode => (
<NetworkRow
network={n}
index={index}
onKeyDown={ctx.onKeyDown}
onToggle={ctx.onToggle}
setRowRef={ctx.setRowRef}
/>
);
type NetworkRowProps = {
network: Network;
index: number;
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onToggle: (id: string, selected: boolean) => void;
setRowRef: (id: string, el: HTMLButtonElement | null) => void;
};
const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: NetworkRowProps) => {
const { t } = useTranslation();
// Same handler is attached to the overlay button and to the network-id copy
// button so arrow nav works wherever focus sits inside the row.
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
return (
<div
className={cn(
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable",
)}
>
<button
type={"button"}
tabIndex={0}
ref={(el) => setRowRef(n.id, el)}
aria-label={t("networks.row.toggle", { name: n.id })}
aria-pressed={n.selected}
onClick={() => onToggle(n.id, n.selected)}
onKeyDown={handleKey}
className={cn(
"absolute inset-0 cursor-pointer outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
/>
<ResourceIconBadge type={resourceTypeOf(n)} />
<div
className={
"min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none"
}
>
<div>
<CopyToClipboard message={n.id} onKeyDown={handleKey}>
<TruncatedText
text={n.id}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}
/>
</CopyToClipboard>
</div>
<Subtitle network={n} onKeyDown={handleKey} />
</div>
<div aria-hidden="true" className={"shrink-0 self-center relative pointer-events-none"}>
<NetworkToggle checked={n.selected} />
</div>
</div>
);
};
const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
const Icon = resourceIconFor(type);
return (
@@ -364,17 +401,22 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
);
};
const Subtitle = ({ network }: { network: Network }) => {
type SubtitleProps = {
network: Network;
onKeyDown: (e: KeyboardEvent<Element>) => void;
};
const Subtitle = ({ network, onKeyDown }: SubtitleProps) => {
if (isDnsRoute(network)) {
const domain = network.domains[0];
const ips = network.resolvedIps[domain] ?? [];
return <DomainSubtitle domain={domain} ips={ips} />;
return <DomainSubtitle domain={domain} ips={ips} onKeyDown={onKeyDown} />;
}
if (network.range && network.range !== INVALID_PREFIX) {
return (
<div>
<CopyToClipboard message={network.range}>
<CopyToClipboard message={network.range} onKeyDown={onKeyDown}>
<TruncatedText
text={network.range}
className={
@@ -389,7 +431,13 @@ const Subtitle = ({ network }: { network: Network }) => {
return null;
};
const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
type DomainSubtitleProps = {
domain: string;
ips: string[];
onKeyDown: (e: KeyboardEvent<Element>) => void;
};
const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
const span = (
<span className={"block text-xs font-mono text-nb-gray-400 truncate max-w-[300px]"}>
{domain}
@@ -397,7 +445,7 @@ const DomainSubtitle = ({ domain, ips }: { domain: string; ips: string[] }) => {
);
return (
<div>
<CopyToClipboard message={domain}>
<CopyToClipboard message={domain} onKeyDown={onKeyDown}>
{ips.length > 0 ? (
<Tooltip
content={<ResolvedIpsTooltip ips={ips} />}

View File

@@ -195,8 +195,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
</button>
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
<span
role="img"
aria-label={t(peerStatusLabelKey(selected.connStatus))}
aria-hidden="true"
className={cn(
"h-2 w-2 rounded-full shrink-0",
dotClass(selected.connStatus),

View File

@@ -1,4 +1,4 @@
import { KeyboardEvent, useEffect, useMemo, useRef, useState } from "react";
import { KeyboardEvent, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
@@ -166,7 +166,6 @@ type PeersListProps = {
};
const PeersList = ({ data, scrollParent }: PeersListProps) => {
const { t } = useTranslation();
const { setSelected } = usePeerDetail();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
@@ -191,7 +190,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
}
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLDivElement>, index: number) => {
const handleRowKeyDown = (e: KeyboardEvent<Element>, index: number) => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
@@ -216,105 +215,139 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
}
};
const setRowRef = (pubKey: string, el: HTMLButtonElement | null) => {
if (el) rowRefs.current.set(pubKey, el);
else rowRefs.current.delete(pubKey);
};
const ctx = useMemo<PeerRowContext>(
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, setSelected],
);
return (
<Virtuoso
<Virtuoso<PeerStatus, PeerRowContext>
ref={virtuosoRef}
data={data}
customScrollParent={scrollParent}
increaseViewportBy={400}
computeItemKey={(_, peer) => peer.pubKey}
components={{ Header: ListTopSpacer }}
itemContent={(index, peer) => {
const isConnected = peer.connStatus === "Connected";
const peerName = shortenDns(peer.fqdn) || peer.ip;
const statusLabel = t(peerStatusLabelKey(peer.connStatus));
return (
<div
role="listitem"
onKeyDown={(e) => handleRowKeyDown(e, index)}
className={cn(
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable",
)}
>
<button
type={"button"}
tabIndex={0}
ref={(el) => {
if (el) rowRefs.current.set(peer.pubKey, el);
else rowRefs.current.delete(peer.pubKey);
}}
aria-label={t("peers.row.label", {
name: peerName,
status: statusLabel,
})}
onClick={() => setSelected(peer)}
className={cn(
"absolute inset-0 cursor-default outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
/>
<Tooltip content={statusLabel} side={"left"}>
<span
role="img"
aria-label={statusLabel}
className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2 relative",
dotClass(peer.connStatus),
)}
/>
</Tooltip>
<div
className={
"min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none"
}
>
<div>
<CopyToClipboard
message={peer.fqdn}
className={"pointer-events-auto"}
>
<TruncatedText
text={shortenDns(peer.fqdn)}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}
/>
</CopyToClipboard>
</div>
<div>
<CopyToClipboard
message={peer.ip}
className={"pointer-events-auto"}
>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{peer.ip}
</span>
</CopyToClipboard>
</div>
</div>
{isConnected && peer.latencyMs > 0 && (
<span
className={cn(
"shrink-0 self-center text-xs tabular-nums relative pointer-events-none",
latencyColor(peer.latencyMs),
)}
>
{peer.latencyMs} ms
</span>
)}
<ChevronRightIcon
size={16}
aria-hidden="true"
className={cn(
"shrink-0 self-center text-nb-gray-300 relative pointer-events-none",
"opacity-0 group-hover:opacity-100 transition-opacity",
)}
/>
</div>
);
}}
context={ctx}
itemContent={renderPeerRow}
/>
);
};
type PeerRowContext = {
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onSelect: (peer: PeerStatus) => void;
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
};
const renderPeerRow = (index: number, peer: PeerStatus, ctx: PeerRowContext): ReactNode => (
<PeerRow
peer={peer}
index={index}
onKeyDown={ctx.onKeyDown}
onSelect={ctx.onSelect}
setRowRef={ctx.setRowRef}
/>
);
type PeerRowProps = {
peer: PeerStatus;
index: number;
onKeyDown: (e: KeyboardEvent<Element>, index: number) => void;
onSelect: (peer: PeerStatus) => void;
setRowRef: (pubKey: string, el: HTMLButtonElement | null) => void;
};
const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps) => {
const { t } = useTranslation();
const isConnected = peer.connStatus === "Connected";
const peerName = shortenDns(peer.fqdn) || peer.ip;
const statusLabel = t(peerStatusLabelKey(peer.connStatus));
const handleKey = (e: KeyboardEvent<Element>) => onKeyDown(e, index);
return (
<div
className={cn(
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0",
"hover:bg-nb-gray-900/40 transition-colors",
"wails-no-draggable",
)}
>
<button
type={"button"}
tabIndex={0}
ref={(el) => setRowRef(peer.pubKey, el)}
aria-label={t("peers.row.label", { name: peerName, status: statusLabel })}
onClick={() => onSelect(peer)}
onKeyDown={handleKey}
className={cn(
"absolute inset-0 cursor-default outline-none",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
/>
<Tooltip content={statusLabel} side={"left"}>
<span
aria-hidden="true"
className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2 relative",
dotClass(peer.connStatus),
)}
/>
</Tooltip>
<div
className={
"min-w-0 flex-1 flex flex-col leading-tight relative pointer-events-none"
}
>
<div>
<CopyToClipboard
message={peer.fqdn}
className={"pointer-events-auto"}
onKeyDown={handleKey}
>
<TruncatedText
text={shortenDns(peer.fqdn)}
className={
"block text-[0.81rem] font-medium text-nb-gray-100 truncate max-w-[300px]"
}
/>
</CopyToClipboard>
</div>
<div>
<CopyToClipboard
message={peer.ip}
className={"pointer-events-auto"}
onKeyDown={handleKey}
>
<span className={"text-xs font-mono text-nb-gray-400 truncate"}>
{peer.ip}
</span>
</CopyToClipboard>
</div>
</div>
{isConnected && peer.latencyMs > 0 && (
<span
className={cn(
"shrink-0 self-center text-xs tabular-nums relative pointer-events-none",
latencyColor(peer.latencyMs),
)}
>
{peer.latencyMs} ms
</span>
)}
<ChevronRightIcon
size={16}
aria-hidden="true"
className={cn(
"shrink-0 self-center text-nb-gray-300 relative pointer-events-none",
"opacity-0 group-hover:opacity-100 transition-opacity",
)}
/>
</div>
);
};

View File

@@ -191,7 +191,7 @@ const ProfilesTable = ({
}: ProfilesTableProps) => {
const { t } = useTranslation();
const [focusedIndex, setFocusedIndex] = useState(0);
const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());
const rowRefs = useRef<Map<string, HTMLTableRowElement>>(new Map());
const focusRow = (index: number) => {
if (index < 0 || index >= ordered.length) return;
@@ -200,55 +200,103 @@ const ProfilesTable = ({
el?.focus();
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLLIElement>, index: number) => {
const actionButtonsIn = (row: HTMLTableRowElement | undefined) =>
Array.from(
row?.querySelectorAll<HTMLButtonElement>(
"button:not([aria-hidden='true']):not([aria-disabled='true'])",
) ?? [],
);
const handleRowKey = (e: KeyboardEvent<HTMLTableRowElement>, index: number): boolean => {
switch (e.key) {
case "ArrowDown":
e.preventDefault();
focusRow(Math.min(index + 1, ordered.length - 1));
break;
return true;
case "ArrowUp":
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
return true;
case "Home":
e.preventDefault();
focusRow(0);
break;
return true;
case "End":
e.preventDefault();
focusRow(ordered.length - 1);
break;
return true;
}
return false;
};
const handleButtonKey = (
e: KeyboardEvent<HTMLTableRowElement>,
index: number,
row: HTMLTableRowElement,
): boolean => {
const buttons = actionButtonsIn(row);
const current = buttons.indexOf(e.target as HTMLButtonElement);
if (current === -1) return false;
switch (e.key) {
case "ArrowDown":
focusRow(Math.min(index + 1, ordered.length - 1));
return true;
case "ArrowUp":
focusRow(Math.max(index - 1, 0));
return true;
case "Escape":
row.focus();
return true;
case "Tab":
// At the last button: jump to the next row instead of exiting the table.
// At the first button with Shift+Tab: jump back to the row.
if (!e.shiftKey && current === buttons.length - 1 && index < ordered.length - 1) {
focusRow(index + 1);
return true;
}
if (e.shiftKey && current === 0) {
row.focus();
return true;
}
return false;
}
return false;
};
const handleRowKeyDown = (e: KeyboardEvent<HTMLTableRowElement>, index: number) => {
const row = rowRefs.current.get(ordered[index].id);
if (!row) return;
const onRow = e.target === row;
const handled = onRow ? handleRowKey(e, index) : handleButtonKey(e, index, row);
if (handled) e.preventDefault();
};
const safeFocusedIndex = Math.min(focusedIndex, Math.max(0, ordered.length - 1));
return (
<ul
role={"list"}
className={"w-full text-sm flex flex-col"}
<table
aria-label={t("settings.profiles.section.profiles")}
className={"w-full text-sm border-separate border-spacing-0"}
>
{ordered.map((profile, index) => (
<ProfileRow
key={profile.id}
profile={profile}
isActive={profile.id === activeProfileId}
isFocused={index === safeFocusedIndex}
isFirst={index === 0}
isLast={index === ordered.length - 1}
rowRef={(el) => {
if (el) rowRefs.current.set(profile.id, el);
else rowRefs.current.delete(profile.id);
}}
onKeyDown={(e) => handleRowKeyDown(e, index)}
onFocus={() => setFocusedIndex(index)}
onSwitch={() => onSwitch(profile.id, profile.name)}
onDeregister={() => onDeregister(profile.id, profile.name)}
onDelete={() => onDelete(profile.id, profile.name)}
/>
))}
</ul>
<tbody className={"flex flex-col"}>
{ordered.map((profile, index) => (
<ProfileRow
key={profile.id}
profile={profile}
isActive={profile.id === activeProfileId}
isFocused={index === safeFocusedIndex}
isFirst={index === 0}
isLast={index === ordered.length - 1}
rowRef={(el) => {
if (el) rowRefs.current.set(profile.id, el);
else rowRefs.current.delete(profile.id);
}}
onKeyDown={(e) => handleRowKeyDown(e, index)}
onFocus={() => setFocusedIndex(index)}
onSwitch={() => onSwitch(profile.id, profile.name)}
onDeregister={() => onDeregister(profile.id, profile.name)}
onDelete={() => onDelete(profile.id, profile.name)}
/>
))}
</tbody>
</table>
);
};
@@ -258,8 +306,8 @@ type ProfileRowProps = {
isFocused: boolean;
isFirst: boolean;
isLast: boolean;
rowRef: (el: HTMLLIElement | null) => void;
onKeyDown: (e: KeyboardEvent<HTMLLIElement>) => void;
rowRef: (el: HTMLTableRowElement | null) => void;
onKeyDown: (e: KeyboardEvent<HTMLTableRowElement>) => void;
onFocus: () => void;
onSwitch: () => void;
onDeregister: () => void;
@@ -284,7 +332,7 @@ const ProfileRow = ({
const showEmail = !!profile.email;
return (
<li
<tr
ref={rowRef}
tabIndex={isFocused ? 0 : -1}
onKeyDown={onKeyDown}
@@ -299,7 +347,7 @@ const ProfileRow = ({
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
)}
>
<div
<td
className={cn(
"flex gap-2 min-w-0 leading-tight flex-1",
showEmail ? "items-start" : "items-center",
@@ -323,8 +371,8 @@ const ProfileRow = ({
</div>
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
</div>
<div className={"shrink-0 text-right"}>
</td>
<td className={"shrink-0 text-right"}>
<RowActions
canSwitch={!isActive}
canDeregister={!!profile.email}
@@ -335,8 +383,8 @@ const ProfileRow = ({
onDeregister={onDeregister}
onDelete={onDelete}
/>
</div>
</li>
</td>
</tr>
);
};

View File

@@ -92,9 +92,12 @@ export function SettingsAbout() {
>
<img src={netbirdFull} alt={t("common.netbird")} className={"h-7 w-auto"} />
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<p
className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"}
<button
type={"button"}
onClick={handleVersionClick}
className={
"text-sm font-semibold text-nb-gray-100 cursor-text select-text bg-transparent outline-none"
}
>
{daemonVersion === "development" ? (
<span>
@@ -106,7 +109,7 @@ export function SettingsAbout() {
) : (
t("settings.about.client", { version: daemonVersion })
)}
</p>
</button>
<p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}>
{guiVersion === "development" ? (
<span>

View File

@@ -12,7 +12,6 @@ export const SectionGroup = ({
}) => (
<section
aria-label={title}
aria-disabled={disabled || undefined}
tabIndex={disabled ? -1 : 0}
{...(disabled ? { inert: "" } : {})}
className={cn(

View File

@@ -95,7 +95,7 @@ export function SettingsTroubleshooting() {
/>
<div
className={"flex items-center gap-6 justify-between"}
{...(!capture ? { inert: "" } : {})}
{...(capture ? {} : { inert: "" })}
>
<div className={"flex-1 max-w-md"}>
<Label htmlFor={durationId} disabled={!capture}>
@@ -225,6 +225,9 @@ function DoneResult({
docs: (
<a
href={SUPPORT_DOCS_URL}
aria-label={t(
"settings.about.community.documentation",
)}
onClick={(e) => {
e.preventDefault();
Browser.OpenURL(SUPPORT_DOCS_URL).catch(() =>
@@ -232,7 +235,10 @@ function DoneResult({
);
}}
className={"text-netbird hover:underline"}
/>
>
{/* content is provided by <Trans> */}
<span />
</a>
),
}}
/>