add a11y keys

This commit is contained in:
Eduard Gert
2026-06-18 09:24:26 +02:00
parent f6e50995b6
commit 9cdc6e3013
51 changed files with 761 additions and 153 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/cn";
@@ -18,6 +19,7 @@ type CopyToClipboardProps = {
iconClassName?: string;
alwaysShowIcon?: boolean;
variant?: CopyToClipboardVariant;
"aria-label"?: string;
};
export const CopyToClipboard = ({
@@ -29,7 +31,9 @@ export const CopyToClipboard = ({
iconClassName,
alwaysShowIcon = false,
variant = "default",
"aria-label": ariaLabel,
}: CopyToClipboardProps) => {
const { t } = useTranslation();
const wrapperRef = useRef<HTMLButtonElement>(null);
const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -55,11 +59,16 @@ export const CopyToClipboard = ({
}
};
const resolvedLabel =
ariaLabel ?? (message ? `${t("common.copy")} ${message}` : t("common.copy"));
return (
<button
type="button"
ref={wrapperRef}
onClick={handleClick}
aria-label={resolvedLabel}
aria-live="polite"
className={cn(
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left pointer-events-auto",
className,
@@ -74,12 +83,14 @@ export const CopyToClipboard = ({
>
{children}
<span
aria-hidden="true"
className={
"absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500 pointer-events-none"
}
/>
</span>
<span
aria-hidden="true"
className={cn(
"shrink-0 inline-flex relative top-[2px] right-[1px]",
iconAlignment === "left" ? "order-first" : "order-last",

View File

@@ -77,6 +77,9 @@ export function LanguagePicker() {
<button
type={"button"}
disabled={busy || languages.length === 0}
aria-label={t("settings.general.language.label")}
aria-haspopup="listbox"
aria-expanded={open}
className={cn(
"inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]",
"rounded-md border bg-white dark:bg-nb-gray-900",
@@ -86,11 +89,19 @@ export function LanguagePicker() {
"disabled:opacity-50",
)}
>
<LanguagesIcon size={16} className={"text-nb-gray-200 shrink-0"} />
<LanguagesIcon
size={16}
aria-hidden="true"
className={"text-nb-gray-200 shrink-0"}
/>
<span className={"truncate flex-1 text-left"}>
{current ? labelFor(current) : "—"}
</span>
<ChevronDown size={12} className={"text-nb-gray-400 shrink-0"} />
<ChevronDown
size={12}
aria-hidden="true"
className={"text-nb-gray-400 shrink-0"}
/>
</button>
</Popover.Trigger>
@@ -119,11 +130,19 @@ export function LanguagePicker() {
)}
>
<div className={"px-1 pb-1"}>
<div className={"group flex items-center gap-2 px-1 h-8"}>
<Search size={14} className={"text-nb-gray-200 shrink-0"} />
<div
role="search"
className={"group flex items-center gap-2 px-1 h-8"}
>
<Search
size={14}
aria-hidden="true"
className={"text-nb-gray-200 shrink-0"}
/>
<Command.Input
autoFocus
placeholder={t("settings.general.language.search")}
aria-label={t("settings.general.language.search")}
className={cn(
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
"outline-none border-none",
@@ -162,6 +181,7 @@ export function LanguagePicker() {
{labelFor(lang)}
</span>
<span
aria-hidden="true"
className={
"w-4 shrink-0 flex items-center justify-center"
}

View File

@@ -18,10 +18,16 @@ export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: P
key={i18n.language}
value={value}
onChange={(v) => onChange(v as ManagementMode)}
aria-label={t("settings.general.management.label")}
className={fullWidth ? "w-full" : undefined}
>
<SwitchItem value={ManagementMode.Cloud} className={itemClass}>
<img src={netbirdLogo} alt={""} className={"h-[0.8rem] aspect-[31/23] shrink-0"} />
<img
src={netbirdLogo}
alt={""}
aria-hidden="true"
className={"h-[0.8rem] aspect-[31/23] shrink-0"}
/>
{t("settings.general.management.cloud")}
</SwitchItem>
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>

View File

@@ -25,6 +25,7 @@ export const SquareIcon = ({
className,
}: SquareIconProps) => (
<div
aria-hidden="true"
className={cn(
"h-11 w-11 rounded-lg flex items-center justify-center border bg-nb-gray-920 border-nb-gray-900",
variantClass[variant],

View File

@@ -54,20 +54,25 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
>
<Icon
size={iconSize}
aria-hidden="true"
className={cn(
"shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
/>
<h2
<span
className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)}
>
{title}
</h2>
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>}
</span>
{adornment && (
<div aria-hidden="true" className={"ml-auto mr-2 shrink-0"}>
{adornment}
</div>
)}
</Tabs.Trigger>
);
});

View File

@@ -144,6 +144,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={ref}
type={type}
disabled={disabled || loading}
aria-busy={loading || undefined}
className={cn(
buttonVariants({
variant,
@@ -170,13 +171,20 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{...props}
>
{loading && (
<span className={"absolute inset-0 flex items-center justify-center"}>
<span
aria-hidden="true"
className={"absolute inset-0 flex items-center justify-center"}
>
<Loader2 size={iconSize} className={"animate-spin"} />
</span>
)}
<span className={cn("contents", loading && "invisible")}>
{copy !== undefined &&
(copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)}
(copied ? (
<Check size={iconSize} aria-hidden="true" />
) : (
<Copy size={iconSize} aria-hidden="true" />
))}
{children}
</span>
</button>

View File

@@ -4,14 +4,22 @@ import { isMacOS } from "@/lib/platform.ts";
type ConfirmDialogProps = {
children: ReactNode;
"aria-label"?: string;
"aria-labelledby"?: string;
};
export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(function ConfirmDialog(
{ children },
{ children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy },
ref,
) {
return (
<div className={"wails-draggable select-none flex flex-col items-center"}>
<div
role="dialog"
aria-modal="true"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={"wails-draggable select-none flex flex-col items-center"}
>
<div
ref={ref}
className={cn(

View File

@@ -32,6 +32,9 @@ export const ConfirmModal = ({
const { t } = useTranslation();
const resolvedCancel = cancelLabel ?? t("common.cancel");
const srTitle = typeof title === "string" ? title : undefined;
const srDescription = typeof description === "string" ? description : undefined;
return (
<Dialog.Root
open={open}
@@ -43,6 +46,8 @@ export const ConfirmModal = ({
maxWidthClass="max-w-sm"
showClose={false}
className="py-5"
srTitle={srTitle}
srDescription={srDescription}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className="flex flex-col gap-5 px-5">
@@ -54,7 +59,12 @@ export const ConfirmModal = ({
</div>
<DialogActions className={"flex-row justify-end gap-2.5"}>
<Button variant={"secondary"} size={"xs2"} disabled={busy} onClick={onCancel}>
<Button
variant={"secondary"}
size={"xs2"}
disabled={busy}
onClick={onCancel}
>
{resolvedCancel}
</Button>
<Button

View File

@@ -20,7 +20,8 @@ const Overlay = forwardRef<ElementRef<typeof DialogPrimitive.Overlay>, OverlayPr
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
"bg-black/60",
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
exitAnimation && "data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
exitAnimation &&
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
"duration-150 ease-out",
className,
)}
@@ -34,6 +35,8 @@ type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showClose?: boolean;
maxWidthClass?: string;
exitAnimation?: boolean;
srTitle?: string;
srDescription?: string;
};
export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>(
@@ -44,6 +47,8 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
showClose = true,
maxWidthClass = "max-w-md",
exitAnimation = false,
srTitle,
srDescription,
...props
},
ref,
@@ -70,8 +75,17 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
{...props}
>
<VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title>
<DialogPrimitive.Title>
{srTitle ?? t("common.netbird")}
</DialogPrimitive.Title>
</VisuallyHidden>
{srDescription && (
<VisuallyHidden asChild>
<DialogPrimitive.Description>
{srDescription}
</DialogPrimitive.Description>
</VisuallyHidden>
)}
{children}
{showClose && (
<DialogPrimitive.Close
@@ -82,7 +96,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
)}
aria-label={t("common.close")}
>
<X className="h-4 w-4" />
<X className="h-4 w-4" aria-hidden="true" />
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>

View File

@@ -13,10 +13,17 @@ type DialogHeadingProps = {
children: ReactNode;
className?: string;
align?: DialogAlign;
id?: string;
};
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => (
<p
export const DialogHeading = ({
children,
className,
align = "center",
id,
}: DialogHeadingProps) => (
<h2
id={id}
className={cn(
"w-full text-base font-semibold text-nb-gray-50 select-none",
alignClass[align],
@@ -24,5 +31,5 @@ export const DialogHeading = ({ children, className, align = "center" }: DialogH
)}
>
{children}
</p>
</h2>
);

View File

@@ -35,9 +35,7 @@ export const DaemonOutdatedOverlay = () => {
<p className={"text-base font-medium text-nb-gray-50"}>
{t("daemon.outdated.title")}
</p>
<p className={"text-sm text-nb-gray-300"}>
{t("daemon.outdated.description")}
</p>
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
</div>
<div className={"wails-no-draggable"}>

View File

@@ -1,4 +1,5 @@
import { forwardRef, InputHTMLAttributes, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/cn";
@@ -8,16 +9,25 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
};
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
{ iconSize = 16, className, disabled, shortcut, ...props },
{ iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props },
ref,
) {
const { t } = useTranslation();
return (
<div className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}>
<SearchIcon size={iconSize} className={"text-nb-gray-300 shrink-0"} />
<div
role="search"
className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}
>
<SearchIcon
size={iconSize}
aria-hidden="true"
className={"text-nb-gray-300 shrink-0"}
/>
<input
ref={ref}
type={"text"}
type={"search"}
disabled={disabled}
aria-label={ariaLabel ?? props.placeholder ?? t("common.search")}
{...props}
className={cn(
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
@@ -28,6 +38,7 @@ export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchIn
/>
{shortcut && (
<span
aria-hidden="true"
className={cn(
"shrink-0 select-none",
"inline-flex items-center justify-center",

View File

@@ -31,13 +31,21 @@ export default function FancyToggleSwitch({
labelClassName,
textWrapperClassName = "max-w-lg",
}: 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 =
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
return (
<div className={cn("inline-block text-left w-full", className)} aria-busy>
<div
role="status"
aria-busy="true"
aria-live="polite"
className={cn("inline-block text-left w-full", className)}
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>
@@ -51,6 +59,7 @@ export default function FancyToggleSwitch({
</div>
<div className={"mt-2 pr-1"}>
<div
aria-hidden="true"
className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"}
/>
</div>
@@ -62,26 +71,18 @@ export default function FancyToggleSwitch({
const fromChildren = (target: EventTarget | null) =>
target instanceof Node && childrenRef.current?.contains(target);
const handleToggle = (event: React.MouseEvent) => {
const handleClick = (event: React.MouseEvent) => {
if (disabled || fromChildren(event.target)) return;
onChange(!value);
};
const handleKeyDown = (event: React.KeyboardEvent) => {
if (disabled || fromChildren(event.target)) return;
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onChange(!value);
}
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={handleToggle}
onKeyDown={handleKeyDown}
tabIndex={-1}
role={"switch"}
aria-checked={value}
onClick={handleClick}
className={cn(
"cursor-default transition-all duration-300 relative z-[1]",
"inline-block text-left w-full",
@@ -91,11 +92,24 @@ export default function FancyToggleSwitch({
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>{label}</Label>
<HelpText margin={false}>{helpText}</HelpText>
<Label as="div" className={labelClassName}>
<label htmlFor={switchId} className={"cursor-default"}>
{label}
</label>
</Label>
<HelpText margin={false}>
<span id={descriptionId}>{helpText}</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<ToggleSwitch checked={value} onCheckedChange={onChange} dataCy={dataCy} />
<ToggleSwitch
ref={switchRef}
id={switchId}
checked={value}
onCheckedChange={onChange}
dataCy={dataCy}
aria-describedby={helpText ? descriptionId : undefined}
/>
</div>
</div>
{children && value ? (

View File

@@ -23,6 +23,8 @@ type Props = {
children: ReactNode;
className?: string;
disabled?: boolean;
"aria-label"?: string;
"aria-labelledby"?: string;
};
export const SwitchItemGroup = ({
@@ -31,6 +33,8 @@ export const SwitchItemGroup = ({
children,
className,
disabled = false,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
}: Props) => {
const layoutId = useId();
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
@@ -41,6 +45,8 @@ export const SwitchItemGroup = ({
value={value}
onValueChange={onChange}
disabled={disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn(
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1 overflow-hidden",
disabled && "opacity-50 pointer-events-none",

View File

@@ -35,7 +35,8 @@ const switchVariants = cva("", {
],
},
"thumb-size": {
default: "h-5 w-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:translate-x-5",
default:
"h-5 w-5 data-[state=unchecked]:translate-x-0 data-[state=checked]:translate-x-5",
small: "h-[14px] w-[14px] data-[state=unchecked]:translate-x-0 data-[state=checked]:translate-x-[17px]",
large: "h-[30px] w-[30px] data-[state=unchecked]:translate-x-[1px] data-[state=checked]:translate-x-[31px]",
},

View File

@@ -43,7 +43,9 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
setMode(saved);
}
})
.catch((err: unknown) => console.warn("[ViewModeContext] load preferences failed", err));
.catch((err: unknown) =>
console.warn("[ViewModeContext] load preferences failed", err),
);
return () => {
cancelled = true;
};

View File

@@ -35,8 +35,7 @@ export const formatErrorMessage = (e: unknown): string => {
const envelope = toWailsEnvelope(e);
// Prefer the structured { short, long } the daemon classifier produced.
const classified =
toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
if (classified) {
const { short, long } = classified;
if (short && long && long !== short) return `${short} Details: ${long}`;

View File

@@ -34,11 +34,13 @@ export default function ErrorDialog() {
}, [close]);
return (
<ConfirmDialog ref={contentRef}>
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-error-dialog-title"}>
<SquareIcon icon={AlertCircleIcon} variant={"danger"} />
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading className={"text-balance"}>{title}</DialogHeading>
<DialogHeading id={"nb-error-dialog-title"} className={"text-balance"}>
{title}
</DialogHeading>
{message && (
<DialogDescription className={"text-balance"}>
<span className={"whitespace-pre-wrap break-words"}>{message}</span>

View File

@@ -52,11 +52,13 @@ export default function LoginWaitingForBrowserDialog() {
}, []);
return (
<ConfirmDialog ref={contentRef}>
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-browser-login-title"}>
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
<div className={"flex flex-col items-center gap-2"}>
<DialogHeading className={"text-balance"}>{t("browserLogin.title")}</DialogHeading>
<DialogHeading id={"nb-browser-login-title"} className={"text-balance"}>
{t("browserLogin.title")}
</DialogHeading>
<DialogDescription>
{t("browserLogin.notSeeing")}{" "}
<button

View File

@@ -235,17 +235,23 @@ export const MainConnectionStatusSwitch = () => {
checked={isOn}
onCheckedChange={handleSwitch}
disabled={(isTransitioning && !canForceCancel) || unreachable}
aria-label={t("connect.toggle.label")}
aria-describedby={"nb-connection-status"}
aria-busy={isTransitioning}
className={cn(unreachable && "opacity-80", isTransitioning && "animate-pulse")}
/>
<div className={"flex flex-col items-center"}>
<h1
<p
id={"nb-connection-status"}
role={"status"}
aria-live={"polite"}
className={
"text-sm font-medium text-nb-gray-200 tracking-wide transition-colors duration-300 select-none wails-no-draggable mb-1"
}
>
{t(STATUS_KEY[connState])}
</h1>
</p>
<CopyToClipboard
message={fqdn}
variant={"bright"}
@@ -270,6 +276,7 @@ export const MainConnectionStatusSwitch = () => {
};
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const hasV6 = !!ipv6;
@@ -303,6 +310,9 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
<Popover.Trigger asChild>
<button
type={"button"}
aria-label={t("connect.localIp.label")}
aria-haspopup="dialog"
aria-expanded={open}
className={cn(
"group relative inline-flex items-center outline-none cursor-default",
"transition-colors",
@@ -319,6 +329,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
</span>
<ChevronDownIcon
size={14}
aria-hidden="true"
className={cn(
"absolute -right-5 top-1/2 -translate-y-1/2",
"shrink-0 text-nb-gray-300 transition-colors",
@@ -352,6 +363,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 handleClick = async () => {
if (!value) return;
@@ -367,6 +379,7 @@ const IpRow = ({ value }: { value: string }) => {
<button
type={"button"}
onClick={handleClick}
aria-label={`${t("common.copy")} ${value}`}
className={cn(
"group/iprow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left",
@@ -375,7 +388,10 @@ const IpRow = ({ value }: { value: string }) => {
)}
>
<span className={"font-mono text-[0.75rem] truncate min-w-0"}>{value}</span>
<span className={"shrink-0 inline-flex items-center text-nb-gray-200"}>
<span
aria-hidden="true"
className={"shrink-0 inline-flex items-center text-nb-gray-200"}
>
{copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
</span>
</button>

View File

@@ -51,6 +51,8 @@ export const MainExitNodeSwitcher = () => {
description={description}
disabled={disabled}
active={!!active}
aria-label={t("exitNodes.dropdown.trigger")}
aria-haspopup="listbox"
/>
</Popover.Trigger>
<Popover.Portal>
@@ -139,6 +141,7 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
{...props}
>
<div
aria-hidden="true"
className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0",
active
@@ -149,7 +152,9 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
<ExitNodeIcon size={14} />
</div>
<div className={"min-w-0 flex-1"}>
<h2 className={"font-medium text-sm text-nb-gray-100 truncate"}>{title}</h2>
<span className={"block font-medium text-sm text-nb-gray-100 truncate"}>
{title}
</span>
<TruncatedText
text={description}
className={
@@ -157,7 +162,11 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
}
/>
</div>
<ChevronsUpDown size={16} className={"text-nb-gray-400 shrink-0"} />
<ChevronsUpDown
size={16}
aria-hidden="true"
className={"text-nb-gray-400 shrink-0"}
/>
</button>
);
},
@@ -181,7 +190,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
)}
>
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span>
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
{isActive && <Check size={16} aria-hidden="true" className={"shrink-0 text-netbird"} />}
</Command.Item>
);
};
@@ -204,7 +213,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
)}
>
<span className={"min-w-0 flex-1 truncate"}>{label}</span>
{isActive && <Check size={16} className={"shrink-0 text-netbird"} />}
{isActive && <Check size={16} aria-hidden="true" className={"shrink-0 text-netbird"} />}
</Command.Item>
);

View File

@@ -75,6 +75,8 @@ export const MainHeader = () => {
icon={MoreVertical}
iconClassName={"text-nb-gray-200 wails-no-draggable"}
className={"select-none"}
aria-label={t("header.menu.open")}
aria-haspopup="menu"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
@@ -86,7 +88,11 @@ export const MainHeader = () => {
<>
<DropdownMenuItem onClick={openAbout}>
<div className="flex items-center gap-2">
<ArrowUpCircleIcon size={14} className={"text-netbird"} />
<ArrowUpCircleIcon
size={14}
className={"text-netbird"}
aria-hidden="true"
/>
<span className={"text-netbird"}>
{t("header.menu.updateAvailable")}
</span>
@@ -97,7 +103,7 @@ export const MainHeader = () => {
)}
<DropdownMenuItem onClick={openSettings}>
<div className="flex items-center gap-2 w-full">
<Settings size={14} />
<Settings size={14} aria-hidden="true" />
<span className="flex-1">{t("header.menu.settings")}</span>
<DropdownMenuShortcut>
{formatShortcut(SETTINGS_SHORTCUT)}
@@ -125,6 +131,7 @@ export const MainHeader = () => {
</DropdownMenu>
{updateAvailable && (
<span
aria-hidden="true"
className={
"pointer-events-none absolute top-1.5 right-1.5 flex h-2.5 w-2.5 items-center justify-center"
}
@@ -172,11 +179,11 @@ type ViewModeItemProps = {
};
const ViewModeItem = ({ icon: Icon, label, selected, onSelect }: ViewModeItemProps) => (
<DropdownMenuItem onClick={onSelect}>
<DropdownMenuItem onClick={onSelect} role="menuitemradio" aria-checked={selected}>
<div className="flex items-center gap-2 w-full">
<Icon size={14} />
<Icon size={14} aria-hidden="true" />
<span className="flex-1">{label}</span>
{selected && <Check size={14} className="text-netbird" />}
{selected && <Check size={14} className="text-netbird" aria-hidden="true" />}
</div>
</DropdownMenuItem>
);

View File

@@ -82,6 +82,11 @@ const AdvancedAppRightPanel = () => {
className={"m-5 ml-0"}
>
<div
ref={(el) => {
if (!el) return;
if (isConnected) el.removeAttribute("inert");
else el.setAttribute("inert", "");
}}
className={cn(
"flex-1 min-h-0 min-w-0 flex flex-col",
!isConnected && "pointer-events-none select-none",
@@ -89,7 +94,12 @@ const AdvancedAppRightPanel = () => {
aria-hidden={!isConnected}
>
<Navigation />
<div className={"flex-1 min-h-0 flex flex-col"}>
<div
role={"tabpanel"}
id={`nb-tabpanel-${section}`}
aria-labelledby={`nb-tab-${section}`}
className={"flex-1 min-h-0 flex flex-col"}
>
{section === "peers" && <Peers />}
{section === "networks" && <Networks />}
</div>

View File

@@ -43,7 +43,12 @@ export const Navigation = () => {
}
return (
<div className={"wails-no-draggable shrink-0 flex items-stretch "}>
<div
role={"tablist"}
aria-orientation={"horizontal"}
aria-label={t("nav.peers.title")}
className={"wails-no-draggable shrink-0 flex items-stretch "}
>
{tabs.map((tab) => {
const isActive = tab.value === section;
const isDisabled = !isConnected && !isActive;
@@ -52,6 +57,11 @@ export const Navigation = () => {
<button
key={tab.value}
type={"button"}
role={"tab"}
aria-selected={isActive}
aria-controls={`nb-tabpanel-${tab.value}`}
id={`nb-tab-${tab.value}`}
tabIndex={isActive ? 0 : -1}
onClick={() => setSection(tab.value)}
disabled={isDisabled}
className={cn(
@@ -62,9 +72,10 @@ export const Navigation = () => {
isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-default",
)}
>
<Icon size={14} />
<Icon size={14} aria-hidden="true" />
<span className={"text-sm font-normal"}>{tab.label}</span>
<span
aria-hidden="true"
className={cn(
"absolute inset-x-0 bottom-0 h-px transition-all",
isActive

View File

@@ -37,6 +37,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
aria-label={t("common.filter")}
className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-200",
@@ -45,11 +46,11 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
"wails-no-draggable cursor-default",
)}
>
<ListFilter size={14} className={"shrink-0"} />
<ListFilter size={14} aria-hidden="true" className={"shrink-0"} />
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
<ChevronDown size={14} aria-hidden="true" className={"ml-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
@@ -58,13 +59,18 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
<DropdownMenuItem
key={f.value}
onClick={() => handleSelect(f.value)}
role="menuitemradio"
aria-checked={checked}
className={"gap-2"}
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>({counts[f.value]})</span>
</span>
<span className={"w-4 shrink-0 flex items-center justify-center"}>
<span
aria-hidden="true"
className={"w-4 shrink-0 flex items-center justify-center"}
>
{checked && <CheckIcon size={14} className={"text-netbird"} />}
</span>
</DropdownMenuItem>

View File

@@ -1,10 +1,4 @@
import {
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
} from "react";
import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso } from "react-virtuoso";
@@ -174,10 +168,7 @@ export const Networks = () => {
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport
ref={setScrollParent}
className={"h-full w-full"}
>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && (
<NetworksList
data={filtered}
@@ -217,6 +208,7 @@ export const Networks = () => {
<button
type={"button"}
onClick={onBulkClick}
aria-label={t("networks.bulk.label")}
className={cn(
"inline-flex items-center h-8 px-3 rounded-md",
"text-xs font-medium text-nb-gray-100",
@@ -252,6 +244,7 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
components={{ Header: NetworksHeader }}
itemContent={(_, n) => (
<div
role="listitem"
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",
@@ -260,7 +253,8 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
>
<button
type={"button"}
aria-label={n.id}
aria-label={t("networks.row.toggle", { name: n.id })}
aria-pressed={n.selected}
onClick={() => onToggle(n.id, n.selected)}
className={"absolute inset-0 cursor-pointer"}
/>
@@ -282,12 +276,11 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
</div>
<Subtitle network={n} />
</div>
<div className={"shrink-0 self-center relative"}>
<NetworkToggle
checked={n.selected}
onChange={() => onToggle(n.id, n.selected)}
label={n.selected ? t("networks.selected") : t("networks.unselected")}
/>
<div
aria-hidden="true"
className={"shrink-0 self-center relative pointer-events-none"}
>
<NetworkToggle checked={n.selected} />
</div>
</div>
)}
@@ -299,6 +292,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
const Icon = resourceIconFor(type);
return (
<div
aria-hidden="true"
className={cn(
"h-9 w-9 shrink-0 rounded-md flex items-center justify-center mt-[0.25rem]",
"bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
@@ -397,23 +391,16 @@ const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
type ToggleProps = {
checked: boolean;
onChange: () => void;
label: string;
mixed?: boolean;
};
const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => {
const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
return (
<button
type={"button"}
role={"switch"}
aria-checked={mixed ? "mixed" : checked}
aria-label={label}
onClick={onChange}
<span
className={cn(
"shrink-0 inline-flex h-5 w-9 items-center rounded-full",
"transition-colors cursor-pointer wails-no-draggable",
"transition-colors wails-no-draggable",
checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
mixed && "opacity-60",
)}
@@ -424,6 +411,6 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => {
mixed ? "translate-x-2.5" : checkedTranslate,
)}
/>
</button>
</span>
);
};

View File

@@ -105,6 +105,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
<AnimatePresence>
{selected && (
<motion.div
role={"dialog"}
aria-modal={"true"}
aria-labelledby={"nb-peer-detail-title"}
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
@@ -128,10 +131,12 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
"wails-no-draggable",
)}
>
<ArrowLeftIcon size={16} />
<ArrowLeftIcon size={16} aria-hidden="true" />
</button>
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
<span
role="img"
aria-label={t(peerStatusLabelKey(selected.connStatus))}
className={cn(
"h-2 w-2 rounded-full shrink-0",
dotClass(selected.connStatus),
@@ -144,7 +149,10 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
className={"flex-1 min-w-0"}
iconClassName={"top-[2px]"}
>
<span className={"text-sm font-medium text-nb-gray-100 truncate"}>
<span
id={"nb-peer-detail-title"}
className={"text-sm font-medium text-nb-gray-100 truncate"}
>
{shortenDns(selected.fqdn) || selected.ip}
</span>
</CopyToClipboard>
@@ -154,6 +162,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
onClick={onRefresh}
disabled={refreshing}
aria-label={t("peers.details.refresh")}
aria-busy={refreshing}
className={cn(
"shrink-0 h-8 w-8 rounded-md flex items-center justify-center",
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
@@ -164,6 +173,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
>
<RefreshCwIcon
size={14}
aria-hidden="true"
className={refreshing ? "animate-spin" : undefined}
/>
</button>
@@ -270,12 +280,16 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
}
>
<div className={"flex gap-1.5 items-center whitespace-nowrap"}>
<ArrowDownIcon size={13} className={"text-sky-400"} />
<ArrowDownIcon
size={13}
aria-hidden="true"
className={"text-sky-400"}
/>
<span className={"sr-only"}>{t("peers.details.bytesReceived")}:</span>
<span className={"tabular-nums"}>{formatBytes(peer.bytesRx)}</span>
</div>
<div className={"flex gap-1.5 items-center whitespace-nowrap"}>
<ArrowUpIcon size={13} className={"text-netbird"} />
<ArrowUpIcon size={13} aria-hidden="true" className={"text-netbird"} />
<span className={"sr-only"}>{t("peers.details.bytesSent")}:</span>
<span className={"tabular-nums"}>{formatBytes(peer.bytesTx)}</span>
</div>
@@ -372,6 +386,8 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
<Popover.Trigger asChild>
<button
type={"button"}
aria-haspopup="dialog"
aria-expanded={open}
className={cn(
"shrink-0 inline-flex items-center gap-1 rounded",
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
@@ -383,6 +399,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
{networks.length}
<ChevronDownIcon
size={12}
aria-hidden="true"
className={cn("transition-transform duration-150", open && "rotate-180")}
/>
</button>
@@ -432,7 +449,11 @@ const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) =
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
<li className={"flex items-center gap-2 px-5 py-4 text-xs text-nb-gray-100 min-w-0"}>
<Icon size={14} className={cn("text-nb-gray-100 shrink-0", iconClassName)} />
<Icon
size={14}
aria-hidden="true"
className={cn("text-nb-gray-100 shrink-0", iconClassName)}
/>
<span className={"text-nb-gray-200 shrink-0 font-semibold"}>{label}</span>
<span
className={cn(

View File

@@ -37,6 +37,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger
disabled={disabled}
aria-label={t("common.filter")}
className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-200",
@@ -45,11 +46,11 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
"wails-no-draggable cursor-default",
)}
>
<ListFilter size={14} className={"shrink-0"} />
<ListFilter size={14} aria-hidden="true" className={"shrink-0"} />
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} className={"ml-0.5 shrink-0"} />
<ChevronDown size={14} aria-hidden="true" className={"ml-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
@@ -58,13 +59,18 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
<DropdownMenuItem
key={f.value}
onClick={() => handleSelect(f.value)}
role="menuitemradio"
aria-checked={checked}
className={"gap-2"}
>
<span className={"flex-1 truncate"}>
{f.label}{" "}
<span className={"tabular-nums"}>({counts[f.value]})</span>
</span>
<span className={"w-4 shrink-0 flex items-center justify-center"}>
<span
aria-hidden="true"
className={"w-4 shrink-0 flex items-center justify-center"}
>
{checked && <CheckIcon size={14} className={"text-netbird"} />}
</span>
</DropdownMenuItem>

View File

@@ -136,13 +136,8 @@ export const Peers = () => {
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport
ref={setScrollParent}
className={"h-full w-full"}
>
{scrollParent && (
<PeersList data={filtered} scrollParent={scrollParent} />
)}
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
</ScrollArea.Viewport>
<ScrollArea.Scrollbar
orientation={"vertical"}
@@ -183,8 +178,11 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
components={{ Header: ListTopSpacer }}
itemContent={(_, peer) => {
const isConnected = peer.connStatus === "Connected";
const peerName = shortenDns(peer.fqdn) || peer.ip;
const statusLabel = t(peerStatusLabelKey(peer.connStatus));
return (
<div
role="listitem"
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",
@@ -193,12 +191,17 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
>
<button
type={"button"}
aria-label={shortenDns(peer.fqdn)}
aria-label={t("peers.row.label", {
name: peerName,
status: statusLabel,
})}
onClick={() => setSelected(peer)}
className={"absolute inset-0 cursor-default"}
/>
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}>
<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),
@@ -246,6 +249,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
)}
<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",

View File

@@ -1,4 +1,4 @@
import { FormEvent, useEffect, useRef, useState } from "react";
import { FormEvent, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import * as Dialog from "@/components/dialog/Dialog";
import { Input } from "@/components/inputs/Input";
@@ -33,6 +33,8 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
const { t } = useTranslation();
const { mdm } = useRestrictions();
const managedManagementUrl = mdm.managementURL;
const nameId = useId();
const urlId = useId();
const [name, setName] = useState("");
const [nameError, setNameError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null);
@@ -126,13 +128,15 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
maxWidthClass="max-w-md"
showClose={false}
className="py-7"
srTitle={t("profile.dialog.title")}
srDescription={t("profile.dialog.description")}
onOpenAutoFocus={(e) => e.preventDefault()}
>
<form onSubmit={handleSubmit}>
<div className="flex flex-col gap-6 px-7">
<div className="flex flex-col gap-2">
<div className={"pl-1"}>
<Label as={"div"} className={"mb-0.5"}>
<Label htmlFor={nameId} className={"mb-0.5"}>
{t("profile.dialog.nameLabel")}
</Label>
<HelpText margin={false}>
@@ -140,6 +144,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
</HelpText>
</div>
<Input
id={nameId}
ref={nameRef}
autoFocus
placeholder={t("profile.dialog.placeholder")}
@@ -171,8 +176,10 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
/>
{mode === ManagementMode.SelfHosted && (
<Input
id={urlId}
ref={urlRef}
autoFocus
aria-label={t("settings.general.management.label")}
placeholder={t(
"settings.general.management.urlPlaceholder",
)}

View File

@@ -124,7 +124,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none",
)}
>
<Settings2 size={14} className="shrink-0" />
<Settings2 size={14} aria-hidden="true" className="shrink-0" />
<span className="truncate flex-1">
{t("profile.dropdown.manageProfiles")}
</span>
@@ -138,9 +138,17 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
};
const ProfileTriggerSkeleton = () => (
<div className="h-10 flex items-center gap-2 px-3 rounded-lg select-none wails-no-draggable">
<div className="size-4 rounded-full bg-nb-gray-900 animate-pulse shrink-0" />
<div className="h-4 w-24 rounded bg-nb-gray-900 animate-pulse" />
<div
role="status"
aria-busy="true"
aria-live="polite"
className="h-10 flex items-center gap-2 px-3 rounded-lg select-none wails-no-draggable"
>
<div
aria-hidden="true"
className="size-4 rounded-full bg-nb-gray-900 animate-pulse shrink-0"
/>
<div aria-hidden="true" className="h-4 w-24 rounded bg-nb-gray-900 animate-pulse" />
</div>
);
@@ -150,11 +158,14 @@ type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
function ProfileTriggerButton({ name, className, ...props }, ref) {
const { t } = useTranslation();
const Icon = pickProfileIcon(name) ?? UserCircle;
return (
<button
ref={ref}
type="button"
aria-label={t("header.profile.switch")}
aria-haspopup="listbox"
className={cn(
"h-10 flex items-center gap-2 px-3 rounded-lg outline-none cursor-default select-none wails-no-draggable",
"text-nb-gray-200 hover:bg-nb-gray-900",
@@ -165,11 +176,19 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
)}
{...props}
>
<Icon size={16} className={"text-nb-gray-200 shrink-0 wails-no-draggable"} />
<Icon
size={16}
aria-hidden="true"
className={"text-nb-gray-200 shrink-0 wails-no-draggable"}
/>
<span className={"text-sm font-medium truncate max-w-[140px] wails-no-draggable"}>
{name}
</span>
<ChevronDown size={14} className={"text-nb-gray-200 shrink-0 wails-no-draggable"} />
<ChevronDown
size={14}
aria-hidden="true"
className={"text-nb-gray-200 shrink-0 wails-no-draggable"}
/>
</button>
);
},
@@ -199,7 +218,11 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
{isActive && (
<Check size={16} className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")} />
<Check
size={16}
aria-hidden="true"
className={cn("shrink-0 text-netbird", showEmail && "mt-0.5")}
/>
)}
</Command.Item>
);

View File

@@ -128,7 +128,10 @@ export function ProfilesTab() {
"bg-nb-gray-930/60 border border-nb-gray-900 rounded-xl overflow-hidden",
)}
>
<table className={"w-full text-sm"}>
<table
className={"w-full text-sm"}
aria-label={t("settings.profiles.section.profiles")}
>
<tbody>
{ordered.map((profile) => (
<ProfileRow
@@ -149,7 +152,11 @@ export function ProfilesTab() {
"flex flex-col items-center justify-center py-10 text-center"
}
>
<UserCircle size={28} className={"text-nb-gray-500 mb-2"} />
<UserCircle
size={28}
aria-hidden="true"
className={"text-nb-gray-500 mb-2"}
/>
<p className={"text-sm font-semibold text-nb-gray-200"}>
{t("settings.profiles.emptyTitle")}
</p>
@@ -162,7 +169,7 @@ export function ProfilesTab() {
<SettingsBottomBar>
<Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}>
<PlusCircle size={14} />
<PlusCircle size={14} aria-hidden="true" />
{t("settings.profiles.addProfile")}
</Button>
</SettingsBottomBar>
@@ -201,6 +208,7 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro
>
<Icon
size={15}
aria-hidden="true"
className={cn(
"text-nb-gray-200 shrink-0",
@@ -346,7 +354,7 @@ const ActionIconButton = ({
"opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
)}
>
<Icon size={16} />
<Icon size={16} aria-hidden="true" />
</button>
);
if (hidden) return button;

View File

@@ -153,11 +153,11 @@ export default function SessionExpirationDialog() {
}, []);
return (
<ConfirmDialog ref={contentRef}>
<ConfirmDialog ref={contentRef} aria-labelledby={"nb-session-expiration-title"}>
<SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} />
<div className={"flex flex-col items-center gap-1"}>
<DialogHeading>
<DialogHeading id={"nb-session-expiration-title"}>
{expired ? t("sessionExpiration.expired") : activeTitle}
</DialogHeading>
<DialogDescription>

View File

@@ -7,12 +7,20 @@ import netbirdFull from "@/assets/logos/netbird-full.svg";
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
<path d={"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"}/>
<path
d={
"M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"
}
/>
</svg>
);
const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}>
<path d={"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"}/>
<path
d={
"M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zM6.313 15.165a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313zM8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zM8.834 6.313a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312zM18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zM17.688 8.834a2.528 2.528 0 0 1-2.523 2.521 2.527 2.527 0 0 1-2.52-2.521V2.522A2.527 2.527 0 0 1 15.165 0a2.528 2.528 0 0 1 2.523 2.522v6.312zM15.165 18.956a2.528 2.528 0 0 1 2.523 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zM15.165 17.688a2.527 2.527 0 0 1-2.52-2.523 2.526 2.526 0 0 1 2.52-2.52h6.313A2.527 2.527 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.523h-6.313z"
}
/>
</svg>
);
import { useSettings } from "@/contexts/SettingsContext.tsx";
@@ -82,10 +90,9 @@ export function SettingsAbout() {
"flex flex-col items-center justify-center gap-4 max-w-2xl mx-auto min-h-[calc(100vh-12rem)]"
}
>
<img src={netbirdFull} alt={"NetBird"} className={"h-7 w-auto"} />
<img src={netbirdFull} alt={t("common.netbird")} className={"h-7 w-auto"} />
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<button
type={"button"}
<p
className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"}
onClick={handleVersionClick}
>
@@ -99,7 +106,7 @@ export function SettingsAbout() {
) : (
t("settings.about.client", { version: daemonVersion })
)}
</button>
</p>
<p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}>
{guiVersion === "development" ? (
<span>
@@ -131,7 +138,7 @@ export function SettingsAbout() {
"inline-flex items-center gap-1.5 decoration-[0.5px] underline-offset-4 hover:text-nb-gray-100 hover:underline transition"
}
>
<Icon className={iconClassName ?? "h-3.5 w-3.5"} />
<Icon aria-hidden="true" className={iconClassName ?? "h-3.5 w-3.5"} />
<span>{label}</span>
</button>
))}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
@@ -21,6 +21,7 @@ export function SettingsGeneral() {
const { mdm, features } = useRestrictions();
const inputRef = useRef<HTMLInputElement>(null);
const managementUrlId = useId();
const prevMode = useRef(mode);
useEffect(() => {
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
@@ -63,7 +64,9 @@ export function SettingsGeneral() {
<div>
<div className={"flex items-start gap-3"}>
<div className={"flex-1 min-w-0"}>
<Label as={"div"}>{t("settings.general.management.label")}</Label>
<Label htmlFor={managementUrlId}>
{t("settings.general.management.label")}
</Label>
<HelpText>{t("settings.general.management.help")}</HelpText>
</div>
<ManagementServerSwitch value={mode} onChange={setMode} />
@@ -71,6 +74,7 @@ export function SettingsGeneral() {
{mode === ManagementMode.SelfHosted && (
<div className={"flex items-start gap-3 mt-2"}>
<Input
id={managementUrlId}
ref={inputRef}
value={displayUrl}
onChange={(e) => setUrl(e.target.value)}

View File

@@ -29,7 +29,7 @@ export const SettingsNavigation = () => {
return (
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}>
<VerticalTabs.List>
<VerticalTabs.List aria-label={t("settings.nav.label")}>
<VerticalTabs.Trigger
value={"general"}
icon={SlidersHorizontalIcon}

View File

@@ -6,12 +6,13 @@ import { Label } from "@/components/typography/Label";
import { cn } from "@/lib/cn";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.tsx";
import { type ChangeEvent, useEffect, useState } from "react";
import { type ChangeEvent, useEffect, useId, useState } from "react";
export function SettingsSSH() {
const { t } = useTranslation();
const { config, setField } = useSettings();
const isSSHServerEnabled = config.serverSshAllowed;
const jwtTtlId = useId();
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
useEffect(() => {
@@ -97,11 +98,12 @@ export function SettingsSSH() {
)}
>
<div className={"flex-1 max-w-md"}>
<Label as={"div"}>{t("settings.ssh.jwtTtl.label")}</Label>
<Label htmlFor={jwtTtlId}>{t("settings.ssh.jwtTtl.label")}</Label>
<HelpText margin={false}>{t("settings.ssh.jwtTtl.help")}</HelpText>
</div>
<div className={"w-40 shrink-0"}>
<Input
id={jwtTtlId}
type={"number"}
min={0}
value={jwtTtlInput}

View File

@@ -10,7 +10,11 @@ export const SectionGroup = ({
children: ReactNode;
disabled?: boolean;
}) => (
<section className={cn("mb-8 last:mb-1 px-1", disabled && "opacity-30 pointer-events-none")}>
<section
aria-label={title}
aria-disabled={disabled || undefined}
className={cn("mb-8 last:mb-1 px-1", disabled && "opacity-30 pointer-events-none")}
>
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
{title}
</h2>
@@ -20,7 +24,7 @@ export const SectionGroup = ({
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
<>
<div className={"h-[3.2rem] shrink-0"} aria-hidden />
<div className={"h-[3.2rem] shrink-0"} aria-hidden="true" />
<div className={"absolute bottom-0 left-0 w-full"}>
<div
className={

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from "react";
import { useId, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next";
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
import { Browser } from "@wailsio/runtime";
@@ -22,6 +22,7 @@ const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues";
export function SettingsTroubleshooting() {
const { t } = useTranslation();
const durationId = useId();
const {
anonymize,
setAnonymize,
@@ -94,7 +95,7 @@ export function SettingsTroubleshooting() {
/>
<div className={"flex items-center gap-6 justify-between"}>
<div className={"flex-1 max-w-md"}>
<Label as={"div"} disabled={!capture}>
<Label htmlFor={durationId} disabled={!capture}>
{t("settings.troubleshooting.duration.label")}
</Label>
<HelpText margin={false} disabled={!capture}>
@@ -103,6 +104,7 @@ export function SettingsTroubleshooting() {
</div>
<div className={"w-40 shrink-0"}>
<Input
id={durationId}
type={"number"}
min={1}
max={30}
@@ -244,6 +246,7 @@ function DoneResult({
<Input
value={result.path}
readOnly
aria-label={t("settings.troubleshooting.done.savedTitle")}
customSuffix={
<button
type={"button"}
@@ -251,7 +254,7 @@ function DoneResult({
className={"pointer-events-auto hover:text-white transition-all"}
aria-label={t("settings.troubleshooting.done.openFileLocation")}
>
<FolderOpen size={16} />
<FolderOpen size={16} aria-hidden="true" />
</button>
}
/>
@@ -259,6 +262,7 @@ function DoneResult({
{uploadFailed && (
<div
role="alert"
className={
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
}
@@ -292,7 +296,7 @@ function DoneResult({
className={"w-full"}
onClick={onRevealPath}
>
<FolderOpen size={14} />
<FolderOpen size={14} aria-hidden="true" />
{t("settings.troubleshooting.done.openFolder")}
</Button>
)

View File

@@ -159,5 +159,12 @@ export default function WelcomeDialog() {
}
}, [initial, step, handleTrayContinue, handleManagementContinue]);
return <ConfirmDialog ref={contentRef}>{content}</ConfirmDialog>;
return (
<ConfirmDialog
ref={contentRef}
aria-labelledby={step === "tray" ? "nb-welcome-title" : "nb-welcome-management-title"}
>
{content}
</ConfirmDialog>
);
}

View File

@@ -81,7 +81,9 @@ export function WelcomeStepManagement({
return (
<>
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
<DialogHeading align={"left"}>{t("welcome.management.title")}</DialogHeading>
<DialogHeading id={"nb-welcome-management-title"} align={"left"}>
{t("welcome.management.title")}
</DialogHeading>
<DialogDescription align={"left"}>
{t("welcome.management.description")}
</DialogDescription>

View File

@@ -35,7 +35,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
</div>
<div className={"flex flex-col w-full gap-1"}>
<DialogHeading align={"left"}>{t("welcome.title")}</DialogHeading>
<DialogHeading id={"nb-welcome-title"} align={"left"}>
{t("welcome.title")}
</DialogHeading>
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
</div>

View File

@@ -359,6 +359,42 @@
"header.menu.updateAvailable": {
"message": "Update verfügbar"
},
"header.menu.open": {
"message": "Menü öffnen"
},
"header.profile.switch": {
"message": "Profil wechseln"
},
"connect.toggle.label": {
"message": "NetBird-Verbindung umschalten"
},
"connect.localIp.label": {
"message": "Lokale IP-Adressen"
},
"common.search": {
"message": "Suchen"
},
"common.filter": {
"message": "Filtern"
},
"exitNodes.dropdown.trigger": {
"message": "Exit-Node auswählen"
},
"peers.row.label": {
"message": "Details öffnen für {name}, {status}"
},
"peers.dialog.title": {
"message": "Peer-Details"
},
"networks.row.toggle": {
"message": "{name} umschalten"
},
"networks.bulk.label": {
"message": "Alle sichtbaren Ressourcen umschalten"
},
"settings.nav.label": {
"message": "Einstellungsbereiche"
},
"profile.switch.title": {
"message": "Zu Profil \"{name}\" wechseln?"
},

View File

@@ -479,6 +479,50 @@
"message": "Update Available",
"description": "'More' menu item / badge shown when an update is available."
},
"header.menu.open": {
"message": "Open menu",
"description": "Accessibility label for the header's more (⋮) button that opens the menu."
},
"header.profile.switch": {
"message": "Switch profile",
"description": "Accessibility label for the header's profile selector button."
},
"connect.toggle.label": {
"message": "Toggle NetBird connection",
"description": "Accessibility label for the large connect/disconnect toggle on the main page."
},
"connect.localIp.label": {
"message": "Local IP addresses",
"description": "Accessibility label for the local IP selector that toggles between IPv4 and IPv6 on the main page."
},
"common.search": {
"message": "Search",
"description": "Accessibility label for a generic search input."
},
"common.filter": {
"message": "Filter",
"description": "Accessibility label for a generic filter control."
},
"exitNodes.dropdown.trigger": {
"message": "Select exit node",
"description": "Accessibility label for the exit-node picker button at the bottom of the main page."
},
"peers.row.label": {
"message": "Open details for {name}, {status}",
"description": "Accessibility label for a peer row in the list. {name} is the peer name and {status} is the connection status; keep both placeholders."
},
"peers.dialog.title": {
"message": "Peer details",
"description": "Accessibility title (announced to screen readers) for the peer details dialog/panel."
},
"networks.row.toggle": {
"message": "Toggle {name}",
"description": "Accessibility label for the row-wide toggle on a network/resource. {name} is the resource id; keep the placeholder."
},
"networks.bulk.label": {
"message": "Toggle all visible resources",
"description": "Accessibility label for the bulk enable/disable button at the bottom of the resources list."
},
"profile.switch.title": {
"message": "Switch Profile to \"{name}\"?",
"description": "Confirmation-dialog title for switching profiles. {name} is the target profile name, shown in quotes; keep {name} and the surrounding quotes."
@@ -599,6 +643,10 @@
"message": "Debug Bundle Failed",
"description": "Error-dialog title when creating the debug bundle fails."
},
"settings.nav.label": {
"message": "Settings sections",
"description": "Accessibility label for the Settings page's side navigation (list of section tabs)."
},
"settings.tabs.general": {
"message": "General",
"description": "Settings tab label: General. Keep short."

View File

@@ -359,6 +359,42 @@
"header.menu.updateAvailable": {
"message": "Actualización disponible"
},
"header.menu.open": {
"message": "Abrir menú"
},
"header.profile.switch": {
"message": "Cambiar de perfil"
},
"connect.toggle.label": {
"message": "Conmutar conexión NetBird"
},
"connect.localIp.label": {
"message": "Direcciones IP locales"
},
"common.search": {
"message": "Buscar"
},
"common.filter": {
"message": "Filtrar"
},
"exitNodes.dropdown.trigger": {
"message": "Seleccionar nodo de salida"
},
"peers.row.label": {
"message": "Abrir detalles de {name}, {status}"
},
"peers.dialog.title": {
"message": "Detalles del par"
},
"networks.row.toggle": {
"message": "Conmutar {name}"
},
"networks.bulk.label": {
"message": "Conmutar todos los recursos visibles"
},
"settings.nav.label": {
"message": "Secciones de configuración"
},
"profile.switch.title": {
"message": "¿Cambiar el perfil a «{name}»?"
},

View File

@@ -359,6 +359,42 @@
"header.menu.updateAvailable": {
"message": "Mise à jour disponible"
},
"header.menu.open": {
"message": "Ouvrir le menu"
},
"header.profile.switch": {
"message": "Changer de profil"
},
"connect.toggle.label": {
"message": "Activer/désactiver la connexion NetBird"
},
"connect.localIp.label": {
"message": "Adresses IP locales"
},
"common.search": {
"message": "Rechercher"
},
"common.filter": {
"message": "Filtrer"
},
"exitNodes.dropdown.trigger": {
"message": "Sélectionner un nœud de sortie"
},
"peers.row.label": {
"message": "Ouvrir les détails pour {name}, {status}"
},
"peers.dialog.title": {
"message": "Détails du pair"
},
"networks.row.toggle": {
"message": "Activer/désactiver {name}"
},
"networks.bulk.label": {
"message": "Activer/désactiver toutes les ressources visibles"
},
"settings.nav.label": {
"message": "Sections des paramètres"
},
"profile.switch.title": {
"message": "Basculer vers le profil « {name} » ?"
},

View File

@@ -359,6 +359,42 @@
"header.menu.updateAvailable": {
"message": "Frissítés elérhető"
},
"header.menu.open": {
"message": "Menü megnyitása"
},
"header.profile.switch": {
"message": "Profilváltás"
},
"connect.toggle.label": {
"message": "NetBird-kapcsolat be/ki"
},
"connect.localIp.label": {
"message": "Helyi IP-címek"
},
"common.search": {
"message": "Keresés"
},
"common.filter": {
"message": "Szűrés"
},
"exitNodes.dropdown.trigger": {
"message": "Kilépőcsomópont kiválasztása"
},
"peers.row.label": {
"message": "Részletek megnyitása: {name}, {status}"
},
"peers.dialog.title": {
"message": "Társ részletei"
},
"networks.row.toggle": {
"message": "{name} be/ki"
},
"networks.bulk.label": {
"message": "Összes látható erőforrás be/ki"
},
"settings.nav.label": {
"message": "Beállítások szakaszai"
},
"profile.switch.title": {
"message": "Váltás a(z) \"{name}\" profilra?"
},

View File

@@ -359,6 +359,42 @@
"header.menu.updateAvailable": {
"message": "Aggiornamento disponibile"
},
"header.menu.open": {
"message": "Apri menu"
},
"header.profile.switch": {
"message": "Cambia profilo"
},
"connect.toggle.label": {
"message": "Attiva/disattiva connessione NetBird"
},
"connect.localIp.label": {
"message": "Indirizzi IP locali"
},
"common.search": {
"message": "Cerca"
},
"common.filter": {
"message": "Filtra"
},
"exitNodes.dropdown.trigger": {
"message": "Seleziona nodo di uscita"
},
"peers.row.label": {
"message": "Apri dettagli di {name}, {status}"
},
"peers.dialog.title": {
"message": "Dettagli peer"
},
"networks.row.toggle": {
"message": "Attiva/disattiva {name}"
},
"networks.bulk.label": {
"message": "Attiva/disattiva tutte le risorse visibili"
},
"settings.nav.label": {
"message": "Sezioni delle impostazioni"
},
"profile.switch.title": {
"message": "Passare al profilo «{name}»?"
},

View File

@@ -359,6 +359,42 @@
"header.menu.updateAvailable": {
"message": "Atualização disponível"
},
"header.menu.open": {
"message": "Abrir menu"
},
"header.profile.switch": {
"message": "Trocar perfil"
},
"connect.toggle.label": {
"message": "Alternar conexão NetBird"
},
"connect.localIp.label": {
"message": "Endereços IP locais"
},
"common.search": {
"message": "Pesquisar"
},
"common.filter": {
"message": "Filtrar"
},
"exitNodes.dropdown.trigger": {
"message": "Selecionar nó de saída"
},
"peers.row.label": {
"message": "Abrir detalhes de {name}, {status}"
},
"peers.dialog.title": {
"message": "Detalhes do par"
},
"networks.row.toggle": {
"message": "Alternar {name}"
},
"networks.bulk.label": {
"message": "Alternar todos os recursos visíveis"
},
"settings.nav.label": {
"message": "Seções das configurações"
},
"profile.switch.title": {
"message": "Alternar perfil para \"{name}\"?"
},

View File

@@ -359,6 +359,42 @@
"header.menu.updateAvailable": {
"message": "Доступно обновление"
},
"header.menu.open": {
"message": "Открыть меню"
},
"header.profile.switch": {
"message": "Сменить профиль"
},
"connect.toggle.label": {
"message": "Переключить подключение NetBird"
},
"connect.localIp.label": {
"message": "Локальные IP-адреса"
},
"common.search": {
"message": "Поиск"
},
"common.filter": {
"message": "Фильтр"
},
"exitNodes.dropdown.trigger": {
"message": "Выбрать выходной узел"
},
"peers.row.label": {
"message": "Открыть подробности для {name}, {status}"
},
"peers.dialog.title": {
"message": "Сведения об узле"
},
"networks.row.toggle": {
"message": "Переключить {name}"
},
"networks.bulk.label": {
"message": "Переключить все видимые ресурсы"
},
"settings.nav.label": {
"message": "Разделы настроек"
},
"profile.switch.title": {
"message": "Переключиться на профиль «{name}»?"
},

View File

@@ -359,6 +359,42 @@
"header.menu.updateAvailable": {
"message": "有可用更新"
},
"header.menu.open": {
"message": "打开菜单"
},
"header.profile.switch": {
"message": "切换配置文件"
},
"connect.toggle.label": {
"message": "切换 NetBird 连接"
},
"connect.localIp.label": {
"message": "本地 IP 地址"
},
"common.search": {
"message": "搜索"
},
"common.filter": {
"message": "筛选"
},
"exitNodes.dropdown.trigger": {
"message": "选择出口节点"
},
"peers.row.label": {
"message": "打开 {name} 的详情,{status}"
},
"peers.dialog.title": {
"message": "对等节点详情"
},
"networks.row.toggle": {
"message": "切换 {name}"
},
"networks.bulk.label": {
"message": "切换所有可见资源"
},
"settings.nav.label": {
"message": "设置部分"
},
"profile.switch.title": {
"message": "切换到配置文件“{name}”?"
},