mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 04:21:29 +02:00
add better keyboard nav and fix sonar lint
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
`;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} />}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -12,7 +12,6 @@ export const SectionGroup = ({
|
||||
}) => (
|
||||
<section
|
||||
aria-label={title}
|
||||
aria-disabled={disabled || undefined}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
{...(disabled ? { inert: "" } : {})}
|
||||
className={cn(
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user