add a11y keys

This commit is contained in:
Eduard Gert
2026-06-18 13:56:41 +02:00
parent f6e50995b6
commit 9cdc6e3013
51 changed files with 761 additions and 153 deletions
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Check, Copy } from "lucide-react"; import { Check, Copy } from "lucide-react";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@@ -18,6 +19,7 @@ type CopyToClipboardProps = {
iconClassName?: string; iconClassName?: string;
alwaysShowIcon?: boolean; alwaysShowIcon?: boolean;
variant?: CopyToClipboardVariant; variant?: CopyToClipboardVariant;
"aria-label"?: string;
}; };
export const CopyToClipboard = ({ export const CopyToClipboard = ({
@@ -29,7 +31,9 @@ export const CopyToClipboard = ({
iconClassName, iconClassName,
alwaysShowIcon = false, alwaysShowIcon = false,
variant = "default", variant = "default",
"aria-label": ariaLabel,
}: CopyToClipboardProps) => { }: CopyToClipboardProps) => {
const { t } = useTranslation();
const wrapperRef = useRef<HTMLButtonElement>(null); const wrapperRef = useRef<HTMLButtonElement>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null); 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 ( return (
<button <button
type="button" type="button"
ref={wrapperRef} ref={wrapperRef}
onClick={handleClick} onClick={handleClick}
aria-label={resolvedLabel}
aria-live="polite"
className={cn( className={cn(
"inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left pointer-events-auto", "inline-flex gap-2 items-center group/copy cursor-default wails-no-draggable text-left pointer-events-auto",
className, className,
@@ -74,12 +83,14 @@ export const CopyToClipboard = ({
> >
{children} {children}
<span <span
aria-hidden="true"
className={ className={
"absolute bottom-0 left-0 right-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500 pointer-events-none" "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>
<span <span
aria-hidden="true"
className={cn( className={cn(
"shrink-0 inline-flex relative top-[2px] right-[1px]", "shrink-0 inline-flex relative top-[2px] right-[1px]",
iconAlignment === "left" ? "order-first" : "order-last", iconAlignment === "left" ? "order-first" : "order-last",
@@ -77,6 +77,9 @@ export function LanguagePicker() {
<button <button
type={"button"} type={"button"}
disabled={busy || languages.length === 0} disabled={busy || languages.length === 0}
aria-label={t("settings.general.language.label")}
aria-haspopup="listbox"
aria-expanded={open}
className={cn( className={cn(
"inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]", "inline-flex items-center gap-2 h-[40px] px-3 min-w-[240px]",
"rounded-md border bg-white dark:bg-nb-gray-900", "rounded-md border bg-white dark:bg-nb-gray-900",
@@ -86,11 +89,19 @@ export function LanguagePicker() {
"disabled:opacity-50", "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"}> <span className={"truncate flex-1 text-left"}>
{current ? labelFor(current) : "—"} {current ? labelFor(current) : "—"}
</span> </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> </button>
</Popover.Trigger> </Popover.Trigger>
@@ -119,11 +130,19 @@ export function LanguagePicker() {
)} )}
> >
<div className={"px-1 pb-1"}> <div className={"px-1 pb-1"}>
<div className={"group flex items-center gap-2 px-1 h-8"}> <div
<Search size={14} className={"text-nb-gray-200 shrink-0"} /> 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 <Command.Input
autoFocus autoFocus
placeholder={t("settings.general.language.search")} placeholder={t("settings.general.language.search")}
aria-label={t("settings.general.language.search")}
className={cn( className={cn(
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300", "w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
"outline-none border-none", "outline-none border-none",
@@ -162,6 +181,7 @@ export function LanguagePicker() {
{labelFor(lang)} {labelFor(lang)}
</span> </span>
<span <span
aria-hidden="true"
className={ className={
"w-4 shrink-0 flex items-center justify-center" "w-4 shrink-0 flex items-center justify-center"
} }
@@ -18,10 +18,16 @@ export const ManagementServerSwitch = ({ value, onChange, fullWidth = false }: P
key={i18n.language} key={i18n.language}
value={value} value={value}
onChange={(v) => onChange(v as ManagementMode)} onChange={(v) => onChange(v as ManagementMode)}
aria-label={t("settings.general.management.label")}
className={fullWidth ? "w-full" : undefined} className={fullWidth ? "w-full" : undefined}
> >
<SwitchItem value={ManagementMode.Cloud} className={itemClass}> <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")} {t("settings.general.management.cloud")}
</SwitchItem> </SwitchItem>
<SwitchItem value={ManagementMode.SelfHosted} className={itemClass}> <SwitchItem value={ManagementMode.SelfHosted} className={itemClass}>
@@ -25,6 +25,7 @@ export const SquareIcon = ({
className, className,
}: SquareIconProps) => ( }: SquareIconProps) => (
<div <div
aria-hidden="true"
className={cn( className={cn(
"h-11 w-11 rounded-lg flex items-center justify-center border bg-nb-gray-920 border-nb-gray-900", "h-11 w-11 rounded-lg flex items-center justify-center border bg-nb-gray-920 border-nb-gray-900",
variantClass[variant], variantClass[variant],
@@ -54,20 +54,25 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
> >
<Icon <Icon
size={iconSize} size={iconSize}
aria-hidden="true"
className={cn( className={cn(
"shrink-0 ml-2 transition-colors duration-150", "shrink-0 ml-2 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100", "text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)} )}
/> />
<h2 <span
className={cn( className={cn(
"font-medium text-sm truncate min-w-0 transition-colors duration-150", "font-medium text-sm truncate min-w-0 transition-colors duration-150",
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100", "text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
)} )}
> >
{title} {title}
</h2> </span>
{adornment && <div className={"ml-auto mr-2 shrink-0"}>{adornment}</div>} {adornment && (
<div aria-hidden="true" className={"ml-auto mr-2 shrink-0"}>
{adornment}
</div>
)}
</Tabs.Trigger> </Tabs.Trigger>
); );
}); });
@@ -144,6 +144,7 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
ref={ref} ref={ref}
type={type} type={type}
disabled={disabled || loading} disabled={disabled || loading}
aria-busy={loading || undefined}
className={cn( className={cn(
buttonVariants({ buttonVariants({
variant, variant,
@@ -170,13 +171,20 @@ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button
{...props} {...props}
> >
{loading && ( {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"} /> <Loader2 size={iconSize} className={"animate-spin"} />
</span> </span>
)} )}
<span className={cn("contents", loading && "invisible")}> <span className={cn("contents", loading && "invisible")}>
{copy !== undefined && {copy !== undefined &&
(copied ? <Check size={iconSize} /> : <Copy size={iconSize} />)} (copied ? (
<Check size={iconSize} aria-hidden="true" />
) : (
<Copy size={iconSize} aria-hidden="true" />
))}
{children} {children}
</span> </span>
</button> </button>
@@ -4,14 +4,22 @@ import { isMacOS } from "@/lib/platform.ts";
type ConfirmDialogProps = { type ConfirmDialogProps = {
children: ReactNode; children: ReactNode;
"aria-label"?: string;
"aria-labelledby"?: string;
}; };
export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(function ConfirmDialog( export const ConfirmDialog = forwardRef<HTMLDivElement, ConfirmDialogProps>(function ConfirmDialog(
{ children }, { children, "aria-label": ariaLabel, "aria-labelledby": ariaLabelledBy },
ref, ref,
) { ) {
return ( 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 <div
ref={ref} ref={ref}
className={cn( className={cn(
@@ -32,6 +32,9 @@ export const ConfirmModal = ({
const { t } = useTranslation(); const { t } = useTranslation();
const resolvedCancel = cancelLabel ?? t("common.cancel"); const resolvedCancel = cancelLabel ?? t("common.cancel");
const srTitle = typeof title === "string" ? title : undefined;
const srDescription = typeof description === "string" ? description : undefined;
return ( return (
<Dialog.Root <Dialog.Root
open={open} open={open}
@@ -43,6 +46,8 @@ export const ConfirmModal = ({
maxWidthClass="max-w-sm" maxWidthClass="max-w-sm"
showClose={false} showClose={false}
className="py-5" className="py-5"
srTitle={srTitle}
srDescription={srDescription}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<div className="flex flex-col gap-5 px-5"> <div className="flex flex-col gap-5 px-5">
@@ -54,7 +59,12 @@ export const ConfirmModal = ({
</div> </div>
<DialogActions className={"flex-row justify-end gap-2.5"}> <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} {resolvedCancel}
</Button> </Button>
<Button <Button
@@ -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", "fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
"bg-black/60", "bg-black/60",
"data-[state=open]:animate-in data-[state=open]:fade-in-0", "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", "duration-150 ease-out",
className, className,
)} )}
@@ -34,6 +35,8 @@ type ContentProps = ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
showClose?: boolean; showClose?: boolean;
maxWidthClass?: string; maxWidthClass?: string;
exitAnimation?: boolean; exitAnimation?: boolean;
srTitle?: string;
srDescription?: string;
}; };
export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>( export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, ContentProps>(
@@ -44,6 +47,8 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
showClose = true, showClose = true,
maxWidthClass = "max-w-md", maxWidthClass = "max-w-md",
exitAnimation = false, exitAnimation = false,
srTitle,
srDescription,
...props ...props
}, },
ref, ref,
@@ -70,8 +75,17 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
{...props} {...props}
> >
<VisuallyHidden asChild> <VisuallyHidden asChild>
<DialogPrimitive.Title>Dialog</DialogPrimitive.Title> <DialogPrimitive.Title>
{srTitle ?? t("common.netbird")}
</DialogPrimitive.Title>
</VisuallyHidden> </VisuallyHidden>
{srDescription && (
<VisuallyHidden asChild>
<DialogPrimitive.Description>
{srDescription}
</DialogPrimitive.Description>
</VisuallyHidden>
)}
{children} {children}
{showClose && ( {showClose && (
<DialogPrimitive.Close <DialogPrimitive.Close
@@ -82,7 +96,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
)} )}
aria-label={t("common.close")} aria-label={t("common.close")}
> >
<X className="h-4 w-4" /> <X className="h-4 w-4" aria-hidden="true" />
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</DialogPrimitive.Content> </DialogPrimitive.Content>
@@ -13,10 +13,17 @@ type DialogHeadingProps = {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
align?: DialogAlign; align?: DialogAlign;
id?: string;
}; };
export const DialogHeading = ({ children, className, align = "center" }: DialogHeadingProps) => ( export const DialogHeading = ({
<p children,
className,
align = "center",
id,
}: DialogHeadingProps) => (
<h2
id={id}
className={cn( className={cn(
"w-full text-base font-semibold text-nb-gray-50 select-none", "w-full text-base font-semibold text-nb-gray-50 select-none",
alignClass[align], alignClass[align],
@@ -24,5 +31,5 @@ export const DialogHeading = ({ children, className, align = "center" }: DialogH
)} )}
> >
{children} {children}
</p> </h2>
); );
@@ -35,9 +35,7 @@ export const DaemonOutdatedOverlay = () => {
<p className={"text-base font-medium text-nb-gray-50"}> <p className={"text-base font-medium text-nb-gray-50"}>
{t("daemon.outdated.title")} {t("daemon.outdated.title")}
</p> </p>
<p className={"text-sm text-nb-gray-300"}> <p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
{t("daemon.outdated.description")}
</p>
</div> </div>
<div className={"wails-no-draggable"}> <div className={"wails-no-draggable"}>
@@ -1,4 +1,5 @@
import { forwardRef, InputHTMLAttributes, ReactNode } from "react"; import { forwardRef, InputHTMLAttributes, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { SearchIcon } from "lucide-react"; import { SearchIcon } from "lucide-react";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
@@ -8,16 +9,25 @@ type Props = InputHTMLAttributes<HTMLInputElement> & {
}; };
export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput( export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchInput(
{ iconSize = 16, className, disabled, shortcut, ...props }, { iconSize = 16, className, disabled, shortcut, "aria-label": ariaLabel, ...props },
ref, ref,
) { ) {
const { t } = useTranslation();
return ( return (
<div className={cn("flex items-center gap-2 px-1 h-10", disabled && "opacity-50")}> <div
<SearchIcon size={iconSize} className={"text-nb-gray-300 shrink-0"} /> 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 <input
ref={ref} ref={ref}
type={"text"} type={"search"}
disabled={disabled} disabled={disabled}
aria-label={ariaLabel ?? props.placeholder ?? t("common.search")}
{...props} {...props}
className={cn( className={cn(
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400", "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 && ( {shortcut && (
<span <span
aria-hidden="true"
className={cn( className={cn(
"shrink-0 select-none", "shrink-0 select-none",
"inline-flex items-center justify-center", "inline-flex items-center justify-center",
@@ -31,13 +31,21 @@ export default function FancyToggleSwitch({
labelClassName, labelClassName,
textWrapperClassName = "max-w-lg", textWrapperClassName = "max-w-lg",
}: Readonly<Props>) { }: Readonly<Props>) {
const switchId = React.useId();
const descriptionId = React.useId();
const childrenRef = React.useRef<HTMLDivElement>(null); const childrenRef = React.useRef<HTMLDivElement>(null);
const switchRef = React.useRef<HTMLButtonElement>(null);
if (loading) { if (loading) {
const shimmer = const shimmer =
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse"; "text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
return ( 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={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}> <div className={cn(textWrapperClassName)}>
<Label className={labelClassName}> <Label className={labelClassName}>
@@ -51,6 +59,7 @@ export default function FancyToggleSwitch({
</div> </div>
<div className={"mt-2 pr-1"}> <div className={"mt-2 pr-1"}>
<div <div
aria-hidden="true"
className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"} className={"h-[24px] w-[44px] rounded-full bg-[#25282d] animate-pulse"}
/> />
</div> </div>
@@ -62,26 +71,18 @@ export default function FancyToggleSwitch({
const fromChildren = (target: EventTarget | null) => const fromChildren = (target: EventTarget | null) =>
target instanceof Node && childrenRef.current?.contains(target); target instanceof Node && childrenRef.current?.contains(target);
const handleToggle = (event: React.MouseEvent) => { const handleClick = (event: React.MouseEvent) => {
if (disabled || fromChildren(event.target)) return; if (disabled || fromChildren(event.target)) return;
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;
const handleKeyDown = (event: React.KeyboardEvent) => { switchRef.current?.click();
if (disabled || fromChildren(event.target)) return; switchRef.current?.focus();
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
onChange(!value);
}
}; };
return ( return (
<div <div
onClick={handleToggle} onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={-1}
role={"switch"}
aria-checked={value}
className={cn( className={cn(
"cursor-default transition-all duration-300 relative z-[1]", "cursor-default transition-all duration-300 relative z-[1]",
"inline-block text-left w-full", "inline-block text-left w-full",
@@ -91,11 +92,24 @@ export default function FancyToggleSwitch({
> >
<div className={"flex justify-between gap-10"}> <div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}> <div className={cn(textWrapperClassName)}>
<Label className={labelClassName}>{label}</Label> <Label as="div" className={labelClassName}>
<HelpText margin={false}>{helpText}</HelpText> <label htmlFor={switchId} className={"cursor-default"}>
{label}
</label>
</Label>
<HelpText margin={false}>
<span id={descriptionId}>{helpText}</span>
</HelpText>
</div> </div>
<div className={"mt-2 pr-1"}> <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>
</div> </div>
{children && value ? ( {children && value ? (
@@ -23,6 +23,8 @@ type Props = {
children: ReactNode; children: ReactNode;
className?: string; className?: string;
disabled?: boolean; disabled?: boolean;
"aria-label"?: string;
"aria-labelledby"?: string;
}; };
export const SwitchItemGroup = ({ export const SwitchItemGroup = ({
@@ -31,6 +33,8 @@ export const SwitchItemGroup = ({
children, children,
className, className,
disabled = false, disabled = false,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
}: Props) => { }: Props) => {
const layoutId = useId(); const layoutId = useId();
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]); const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
@@ -41,6 +45,8 @@ export const SwitchItemGroup = ({
value={value} value={value}
onValueChange={onChange} onValueChange={onChange}
disabled={disabled} disabled={disabled}
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn( className={cn(
"flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1 overflow-hidden", "flex shrink-0 rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1 overflow-hidden",
disabled && "opacity-50 pointer-events-none", disabled && "opacity-50 pointer-events-none",
@@ -35,7 +35,8 @@ const switchVariants = cva("", {
], ],
}, },
"thumb-size": { "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]", 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]", large: "h-[30px] w-[30px] data-[state=unchecked]:translate-x-[1px] data-[state=checked]:translate-x-[31px]",
}, },
@@ -43,7 +43,9 @@ export const ViewModeProvider = ({ children }: { children: ReactNode }) => {
setMode(saved); setMode(saved);
} }
}) })
.catch((err: unknown) => console.warn("[ViewModeContext] load preferences failed", err)); .catch((err: unknown) =>
console.warn("[ViewModeContext] load preferences failed", err),
);
return () => { return () => {
cancelled = true; cancelled = true;
}; };
+1 -2
View File
@@ -35,8 +35,7 @@ export const formatErrorMessage = (e: unknown): string => {
const envelope = toWailsEnvelope(e); const envelope = toWailsEnvelope(e);
// Prefer the structured { short, long } the daemon classifier produced. // Prefer the structured { short, long } the daemon classifier produced.
const classified = const classified = toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
toClassifiedError(envelope?.cause) ?? toClassifiedError(envelope);
if (classified) { if (classified) {
const { short, long } = classified; const { short, long } = classified;
if (short && long && long !== short) return `${short} Details: ${long}`; if (short && long && long !== short) return `${short} Details: ${long}`;
@@ -34,11 +34,13 @@ export default function ErrorDialog() {
}, [close]); }, [close]);
return ( return (
<ConfirmDialog ref={contentRef}> <ConfirmDialog ref={contentRef} aria-labelledby={"nb-error-dialog-title"}>
<SquareIcon icon={AlertCircleIcon} variant={"danger"} /> <SquareIcon icon={AlertCircleIcon} variant={"danger"} />
<div className={"flex flex-col items-center gap-1"}> <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 && ( {message && (
<DialogDescription className={"text-balance"}> <DialogDescription className={"text-balance"}>
<span className={"whitespace-pre-wrap break-words"}>{message}</span> <span className={"whitespace-pre-wrap break-words"}>{message}</span>
@@ -52,11 +52,13 @@ export default function LoginWaitingForBrowserDialog() {
}, []); }, []);
return ( return (
<ConfirmDialog ref={contentRef}> <ConfirmDialog ref={contentRef} aria-labelledby={"nb-browser-login-title"}>
<SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} /> <SquareIcon icon={Loader2} className={"[&_svg]:animate-spin"} />
<div className={"flex flex-col items-center gap-2"}> <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> <DialogDescription>
{t("browserLogin.notSeeing")}{" "} {t("browserLogin.notSeeing")}{" "}
<button <button
@@ -235,17 +235,23 @@ export const MainConnectionStatusSwitch = () => {
checked={isOn} checked={isOn}
onCheckedChange={handleSwitch} onCheckedChange={handleSwitch}
disabled={(isTransitioning && !canForceCancel) || unreachable} 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")} className={cn(unreachable && "opacity-80", isTransitioning && "animate-pulse")}
/> />
<div className={"flex flex-col items-center"}> <div className={"flex flex-col items-center"}>
<h1 <p
id={"nb-connection-status"}
role={"status"}
aria-live={"polite"}
className={ className={
"text-sm font-medium text-nb-gray-200 tracking-wide transition-colors duration-300 select-none wails-no-draggable mb-1" "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])} {t(STATUS_KEY[connState])}
</h1> </p>
<CopyToClipboard <CopyToClipboard
message={fqdn} message={fqdn}
variant={"bright"} variant={"bright"}
@@ -270,6 +276,7 @@ export const MainConnectionStatusSwitch = () => {
}; };
const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => { const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boolean }) => {
const { t } = useTranslation();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const hasV6 = !!ipv6; const hasV6 = !!ipv6;
@@ -303,6 +310,9 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
<Popover.Trigger asChild> <Popover.Trigger asChild>
<button <button
type={"button"} type={"button"}
aria-label={t("connect.localIp.label")}
aria-haspopup="dialog"
aria-expanded={open}
className={cn( className={cn(
"group relative inline-flex items-center outline-none cursor-default", "group relative inline-flex items-center outline-none cursor-default",
"transition-colors", "transition-colors",
@@ -319,6 +329,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
</span> </span>
<ChevronDownIcon <ChevronDownIcon
size={14} size={14}
aria-hidden="true"
className={cn( className={cn(
"absolute -right-5 top-1/2 -translate-y-1/2", "absolute -right-5 top-1/2 -translate-y-1/2",
"shrink-0 text-nb-gray-300 transition-colors", "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 IpRow = ({ value }: { value: string }) => {
const { t } = useTranslation();
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const handleClick = async () => { const handleClick = async () => {
if (!value) return; if (!value) return;
@@ -367,6 +379,7 @@ const IpRow = ({ value }: { value: string }) => {
<button <button
type={"button"} type={"button"}
onClick={handleClick} onClick={handleClick}
aria-label={`${t("common.copy")} ${value}`}
className={cn( className={cn(
"group/iprow relative flex items-center justify-between gap-3", "group/iprow relative flex items-center justify-between gap-3",
"rounded-md px-2 py-1.5 text-left", "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={"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} />} {copied ? <CheckIcon size={11} /> : <CopyIcon size={11} />}
</span> </span>
</button> </button>
@@ -51,6 +51,8 @@ export const MainExitNodeSwitcher = () => {
description={description} description={description}
disabled={disabled} disabled={disabled}
active={!!active} active={!!active}
aria-label={t("exitNodes.dropdown.trigger")}
aria-haspopup="listbox"
/> />
</Popover.Trigger> </Popover.Trigger>
<Popover.Portal> <Popover.Portal>
@@ -139,6 +141,7 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
{...props} {...props}
> >
<div <div
aria-hidden="true"
className={cn( className={cn(
"h-9 w-9 rounded-md flex items-center justify-center shrink-0", "h-9 w-9 rounded-md flex items-center justify-center shrink-0",
active active
@@ -149,7 +152,9 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
<ExitNodeIcon size={14} /> <ExitNodeIcon size={14} />
</div> </div>
<div className={"min-w-0 flex-1"}> <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 <TruncatedText
text={description} text={description}
className={ className={
@@ -157,7 +162,11 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
} }
/> />
</div> </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> </button>
); );
}, },
@@ -181,7 +190,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
)} )}
> >
<span className={"min-w-0 flex-1 truncate"}>{t("exitNodes.dropdown.noneTitle")}</span> <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> </Command.Item>
); );
}; };
@@ -204,7 +213,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
)} )}
> >
<span className={"min-w-0 flex-1 truncate"}>{label}</span> <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> </Command.Item>
); );
@@ -75,6 +75,8 @@ export const MainHeader = () => {
icon={MoreVertical} icon={MoreVertical}
iconClassName={"text-nb-gray-200 wails-no-draggable"} iconClassName={"text-nb-gray-200 wails-no-draggable"}
className={"select-none"} className={"select-none"}
aria-label={t("header.menu.open")}
aria-haspopup="menu"
/> />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent <DropdownMenuContent
@@ -86,7 +88,11 @@ export const MainHeader = () => {
<> <>
<DropdownMenuItem onClick={openAbout}> <DropdownMenuItem onClick={openAbout}>
<div className="flex items-center gap-2"> <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"}> <span className={"text-netbird"}>
{t("header.menu.updateAvailable")} {t("header.menu.updateAvailable")}
</span> </span>
@@ -97,7 +103,7 @@ export const MainHeader = () => {
)} )}
<DropdownMenuItem onClick={openSettings}> <DropdownMenuItem onClick={openSettings}>
<div className="flex items-center gap-2 w-full"> <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> <span className="flex-1">{t("header.menu.settings")}</span>
<DropdownMenuShortcut> <DropdownMenuShortcut>
{formatShortcut(SETTINGS_SHORTCUT)} {formatShortcut(SETTINGS_SHORTCUT)}
@@ -125,6 +131,7 @@ export const MainHeader = () => {
</DropdownMenu> </DropdownMenu>
{updateAvailable && ( {updateAvailable && (
<span <span
aria-hidden="true"
className={ className={
"pointer-events-none absolute top-1.5 right-1.5 flex h-2.5 w-2.5 items-center justify-center" "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) => ( 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"> <div className="flex items-center gap-2 w-full">
<Icon size={14} /> <Icon size={14} aria-hidden="true" />
<span className="flex-1">{label}</span> <span className="flex-1">{label}</span>
{selected && <Check size={14} className="text-netbird" />} {selected && <Check size={14} className="text-netbird" aria-hidden="true" />}
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
); );
@@ -82,6 +82,11 @@ const AdvancedAppRightPanel = () => {
className={"m-5 ml-0"} className={"m-5 ml-0"}
> >
<div <div
ref={(el) => {
if (!el) return;
if (isConnected) el.removeAttribute("inert");
else el.setAttribute("inert", "");
}}
className={cn( className={cn(
"flex-1 min-h-0 min-w-0 flex flex-col", "flex-1 min-h-0 min-w-0 flex flex-col",
!isConnected && "pointer-events-none select-none", !isConnected && "pointer-events-none select-none",
@@ -89,7 +94,12 @@ const AdvancedAppRightPanel = () => {
aria-hidden={!isConnected} aria-hidden={!isConnected}
> >
<Navigation /> <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 === "peers" && <Peers />}
{section === "networks" && <Networks />} {section === "networks" && <Networks />}
</div> </div>
@@ -43,7 +43,12 @@ export const Navigation = () => {
} }
return ( 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) => { {tabs.map((tab) => {
const isActive = tab.value === section; const isActive = tab.value === section;
const isDisabled = !isConnected && !isActive; const isDisabled = !isConnected && !isActive;
@@ -52,6 +57,11 @@ export const Navigation = () => {
<button <button
key={tab.value} key={tab.value}
type={"button"} 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)} onClick={() => setSection(tab.value)}
disabled={isDisabled} disabled={isDisabled}
className={cn( className={cn(
@@ -62,9 +72,10 @@ export const Navigation = () => {
isDisabled ? "opacity-50 cursor-not-allowed" : "cursor-default", 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 className={"text-sm font-normal"}>{tab.label}</span>
<span <span
aria-hidden="true"
className={cn( className={cn(
"absolute inset-x-0 bottom-0 h-px transition-all", "absolute inset-x-0 bottom-0 h-px transition-all",
isActive isActive
@@ -37,6 +37,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled} disabled={disabled}
aria-label={t("common.filter")}
className={cn( className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md", "inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-200", "text-sm text-nb-gray-200",
@@ -45,11 +46,11 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
"wails-no-draggable cursor-default", "wails-no-draggable cursor-default",
)} )}
> >
<ListFilter size={14} className={"shrink-0"} /> <ListFilter size={14} aria-hidden="true" className={"shrink-0"} />
<span> <span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span> {active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span> </span>
<ChevronDown size={14} className={"ml-0.5 shrink-0"} /> <ChevronDown size={14} aria-hidden="true" className={"ml-0.5 shrink-0"} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}> <DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => { {filters.map((f) => {
@@ -58,13 +59,18 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
<DropdownMenuItem <DropdownMenuItem
key={f.value} key={f.value}
onClick={() => handleSelect(f.value)} onClick={() => handleSelect(f.value)}
role="menuitemradio"
aria-checked={checked}
className={"gap-2"} className={"gap-2"}
> >
<span className={"flex-1 truncate"}> <span className={"flex-1 truncate"}>
{f.label}{" "} {f.label}{" "}
<span className={"tabular-nums"}>({counts[f.value]})</span> <span className={"tabular-nums"}>({counts[f.value]})</span>
</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"} />} {checked && <CheckIcon size={14} className={"text-netbird"} />}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
@@ -1,10 +1,4 @@
import { import { useEffect, useMemo, useRef, useState, type ComponentType } from "react";
useEffect,
useMemo,
useRef,
useState,
type ComponentType,
} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import * as ScrollArea from "@radix-ui/react-scroll-area"; import * as ScrollArea from "@radix-ui/react-scroll-area";
import { Virtuoso } from "react-virtuoso"; import { Virtuoso } from "react-virtuoso";
@@ -174,10 +168,7 @@ export const Networks = () => {
<NoResults /> <NoResults />
) : ( ) : (
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}> <ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport <ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
ref={setScrollParent}
className={"h-full w-full"}
>
{scrollParent && ( {scrollParent && (
<NetworksList <NetworksList
data={filtered} data={filtered}
@@ -217,6 +208,7 @@ export const Networks = () => {
<button <button
type={"button"} type={"button"}
onClick={onBulkClick} onClick={onBulkClick}
aria-label={t("networks.bulk.label")}
className={cn( className={cn(
"inline-flex items-center h-8 px-3 rounded-md", "inline-flex items-center h-8 px-3 rounded-md",
"text-xs font-medium text-nb-gray-100", "text-xs font-medium text-nb-gray-100",
@@ -252,6 +244,7 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
components={{ Header: NetworksHeader }} components={{ Header: NetworksHeader }}
itemContent={(_, n) => ( itemContent={(_, n) => (
<div <div
role="listitem"
className={cn( className={cn(
"group relative flex items-start gap-2.5 pl-6 pr-9 py-3 min-w-0", "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", "hover:bg-nb-gray-900/40 transition-colors",
@@ -260,7 +253,8 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
> >
<button <button
type={"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)} onClick={() => onToggle(n.id, n.selected)}
className={"absolute inset-0 cursor-pointer"} className={"absolute inset-0 cursor-pointer"}
/> />
@@ -282,12 +276,11 @@ const NetworksList = ({ data, onToggle, scrollParent }: NetworksListProps) => {
</div> </div>
<Subtitle network={n} /> <Subtitle network={n} />
</div> </div>
<div className={"shrink-0 self-center relative"}> <div
<NetworkToggle aria-hidden="true"
checked={n.selected} className={"shrink-0 self-center relative pointer-events-none"}
onChange={() => onToggle(n.id, n.selected)} >
label={n.selected ? t("networks.selected") : t("networks.unselected")} <NetworkToggle checked={n.selected} />
/>
</div> </div>
</div> </div>
)} )}
@@ -299,6 +292,7 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
const Icon = resourceIconFor(type); const Icon = resourceIconFor(type);
return ( return (
<div <div
aria-hidden="true"
className={cn( className={cn(
"h-9 w-9 shrink-0 rounded-md flex items-center justify-center mt-[0.25rem]", "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", "bg-nb-gray-920 border border-nb-gray-900 text-nb-gray-300",
@@ -397,23 +391,16 @@ const ResolvedIpsTooltip = ({ ips }: { ips: string[] }) => {
type ToggleProps = { type ToggleProps = {
checked: boolean; checked: boolean;
onChange: () => void;
label: string;
mixed?: boolean; 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"; const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
return ( return (
<button <span
type={"button"}
role={"switch"}
aria-checked={mixed ? "mixed" : checked}
aria-label={label}
onClick={onChange}
className={cn( className={cn(
"shrink-0 inline-flex h-5 w-9 items-center rounded-full", "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", checked || mixed ? "bg-netbird" : "bg-nb-gray-700",
mixed && "opacity-60", mixed && "opacity-60",
)} )}
@@ -424,6 +411,6 @@ const NetworkToggle = ({ checked, onChange, label, mixed }: ToggleProps) => {
mixed ? "translate-x-2.5" : checkedTranslate, mixed ? "translate-x-2.5" : checkedTranslate,
)} )}
/> />
</button> </span>
); );
}; };
@@ -105,6 +105,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
<AnimatePresence> <AnimatePresence>
{selected && ( {selected && (
<motion.div <motion.div
role={"dialog"}
aria-modal={"true"}
aria-labelledby={"nb-peer-detail-title"}
initial={{ x: "100%" }} initial={{ x: "100%" }}
animate={{ x: 0 }} animate={{ x: 0 }}
exit={{ x: "100%" }} exit={{ x: "100%" }}
@@ -128,10 +131,12 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
"wails-no-draggable", "wails-no-draggable",
)} )}
> >
<ArrowLeftIcon size={16} /> <ArrowLeftIcon size={16} aria-hidden="true" />
</button> </button>
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}> <Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
<span <span
role="img"
aria-label={t(peerStatusLabelKey(selected.connStatus))}
className={cn( className={cn(
"h-2 w-2 rounded-full shrink-0", "h-2 w-2 rounded-full shrink-0",
dotClass(selected.connStatus), dotClass(selected.connStatus),
@@ -144,7 +149,10 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
className={"flex-1 min-w-0"} className={"flex-1 min-w-0"}
iconClassName={"top-[2px]"} 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} {shortenDns(selected.fqdn) || selected.ip}
</span> </span>
</CopyToClipboard> </CopyToClipboard>
@@ -154,6 +162,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
onClick={onRefresh} onClick={onRefresh}
disabled={refreshing} disabled={refreshing}
aria-label={t("peers.details.refresh")} aria-label={t("peers.details.refresh")}
aria-busy={refreshing}
className={cn( className={cn(
"shrink-0 h-8 w-8 rounded-md flex items-center justify-center", "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", "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 <RefreshCwIcon
size={14} size={14}
aria-hidden="true"
className={refreshing ? "animate-spin" : undefined} className={refreshing ? "animate-spin" : undefined}
/> />
</button> </button>
@@ -270,12 +280,16 @@ const PeerDetails = ({ peer, now }: { peer: PeerStatus; now: number }) => {
} }
> >
<div className={"flex gap-1.5 items-center whitespace-nowrap"}> <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={"sr-only"}>{t("peers.details.bytesReceived")}:</span>
<span className={"tabular-nums"}>{formatBytes(peer.bytesRx)}</span> <span className={"tabular-nums"}>{formatBytes(peer.bytesRx)}</span>
</div> </div>
<div className={"flex gap-1.5 items-center whitespace-nowrap"}> <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={"sr-only"}>{t("peers.details.bytesSent")}:</span>
<span className={"tabular-nums"}>{formatBytes(peer.bytesTx)}</span> <span className={"tabular-nums"}>{formatBytes(peer.bytesTx)}</span>
</div> </div>
@@ -372,6 +386,8 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
<Popover.Trigger asChild> <Popover.Trigger asChild>
<button <button
type={"button"} type={"button"}
aria-haspopup="dialog"
aria-expanded={open}
className={cn( className={cn(
"shrink-0 inline-flex items-center gap-1 rounded", "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", "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} {networks.length}
<ChevronDownIcon <ChevronDownIcon
size={12} size={12}
aria-hidden="true"
className={cn("transition-transform duration-150", open && "rotate-180")} className={cn("transition-transform duration-150", open && "rotate-180")}
/> />
</button> </button>
@@ -432,7 +449,11 @@ const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) =
const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => ( 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"}> <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={"text-nb-gray-200 shrink-0 font-semibold"}>{label}</span>
<span <span
className={cn( className={cn(
@@ -37,6 +37,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
<DropdownMenu open={open} onOpenChange={setOpen}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger <DropdownMenuTrigger
disabled={disabled} disabled={disabled}
aria-label={t("common.filter")}
className={cn( className={cn(
"inline-flex items-center gap-1.5 h-9 px-2 rounded-md", "inline-flex items-center gap-1.5 h-9 px-2 rounded-md",
"text-sm text-nb-gray-200", "text-sm text-nb-gray-200",
@@ -45,11 +46,11 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
"wails-no-draggable cursor-default", "wails-no-draggable cursor-default",
)} )}
> >
<ListFilter size={14} className={"shrink-0"} /> <ListFilter size={14} aria-hidden="true" className={"shrink-0"} />
<span> <span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span> {active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span> </span>
<ChevronDown size={14} className={"ml-0.5 shrink-0"} /> <ChevronDown size={14} aria-hidden="true" className={"ml-0.5 shrink-0"} />
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}> <DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => { {filters.map((f) => {
@@ -58,13 +59,18 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
<DropdownMenuItem <DropdownMenuItem
key={f.value} key={f.value}
onClick={() => handleSelect(f.value)} onClick={() => handleSelect(f.value)}
role="menuitemradio"
aria-checked={checked}
className={"gap-2"} className={"gap-2"}
> >
<span className={"flex-1 truncate"}> <span className={"flex-1 truncate"}>
{f.label}{" "} {f.label}{" "}
<span className={"tabular-nums"}>({counts[f.value]})</span> <span className={"tabular-nums"}>({counts[f.value]})</span>
</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"} />} {checked && <CheckIcon size={14} className={"text-netbird"} />}
</span> </span>
</DropdownMenuItem> </DropdownMenuItem>
@@ -136,13 +136,8 @@ export const Peers = () => {
<NoResults /> <NoResults />
) : ( ) : (
<ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}> <ScrollArea.Root type={"auto"} className={"flex-1 min-h-0 overflow-hidden"}>
<ScrollArea.Viewport <ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
ref={setScrollParent} {scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
className={"h-full w-full"}
>
{scrollParent && (
<PeersList data={filtered} scrollParent={scrollParent} />
)}
</ScrollArea.Viewport> </ScrollArea.Viewport>
<ScrollArea.Scrollbar <ScrollArea.Scrollbar
orientation={"vertical"} orientation={"vertical"}
@@ -183,8 +178,11 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
components={{ Header: ListTopSpacer }} components={{ Header: ListTopSpacer }}
itemContent={(_, peer) => { itemContent={(_, peer) => {
const isConnected = peer.connStatus === "Connected"; const isConnected = peer.connStatus === "Connected";
const peerName = shortenDns(peer.fqdn) || peer.ip;
const statusLabel = t(peerStatusLabelKey(peer.connStatus));
return ( return (
<div <div
role="listitem"
className={cn( className={cn(
"group relative flex items-start gap-2.5 pl-6 pr-4 py-3 min-w-0", "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", "hover:bg-nb-gray-900/40 transition-colors",
@@ -193,12 +191,17 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
> >
<button <button
type={"button"} type={"button"}
aria-label={shortenDns(peer.fqdn)} aria-label={t("peers.row.label", {
name: peerName,
status: statusLabel,
})}
onClick={() => setSelected(peer)} onClick={() => setSelected(peer)}
className={"absolute inset-0 cursor-default"} className={"absolute inset-0 cursor-default"}
/> />
<Tooltip content={t(peerStatusLabelKey(peer.connStatus))} side={"left"}> <Tooltip content={statusLabel} side={"left"}>
<span <span
role="img"
aria-label={statusLabel}
className={cn( className={cn(
"h-2 w-2 rounded-full shrink-0 mt-2 relative", "h-2 w-2 rounded-full shrink-0 mt-2 relative",
dotClass(peer.connStatus), dotClass(peer.connStatus),
@@ -246,6 +249,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
)} )}
<ChevronRightIcon <ChevronRightIcon
size={16} size={16}
aria-hidden="true"
className={cn( className={cn(
"shrink-0 self-center text-nb-gray-300 relative pointer-events-none", "shrink-0 self-center text-nb-gray-300 relative pointer-events-none",
"opacity-0 group-hover:opacity-100 transition-opacity", "opacity-0 group-hover:opacity-100 transition-opacity",
@@ -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 { useTranslation } from "react-i18next";
import * as Dialog from "@/components/dialog/Dialog"; import * as Dialog from "@/components/dialog/Dialog";
import { Input } from "@/components/inputs/Input"; import { Input } from "@/components/inputs/Input";
@@ -33,6 +33,8 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
const { t } = useTranslation(); const { t } = useTranslation();
const { mdm } = useRestrictions(); const { mdm } = useRestrictions();
const managedManagementUrl = mdm.managementURL; const managedManagementUrl = mdm.managementURL;
const nameId = useId();
const urlId = useId();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [nameError, setNameError] = useState<string | null>(null); const [nameError, setNameError] = useState<string | null>(null);
const nameRef = useRef<HTMLInputElement>(null); const nameRef = useRef<HTMLInputElement>(null);
@@ -126,13 +128,15 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
maxWidthClass="max-w-md" maxWidthClass="max-w-md"
showClose={false} showClose={false}
className="py-7" className="py-7"
srTitle={t("profile.dialog.title")}
srDescription={t("profile.dialog.description")}
onOpenAutoFocus={(e) => e.preventDefault()} onOpenAutoFocus={(e) => e.preventDefault()}
> >
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="flex flex-col gap-6 px-7"> <div className="flex flex-col gap-6 px-7">
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div className={"pl-1"}> <div className={"pl-1"}>
<Label as={"div"} className={"mb-0.5"}> <Label htmlFor={nameId} className={"mb-0.5"}>
{t("profile.dialog.nameLabel")} {t("profile.dialog.nameLabel")}
</Label> </Label>
<HelpText margin={false}> <HelpText margin={false}>
@@ -140,6 +144,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
</HelpText> </HelpText>
</div> </div>
<Input <Input
id={nameId}
ref={nameRef} ref={nameRef}
autoFocus autoFocus
placeholder={t("profile.dialog.placeholder")} placeholder={t("profile.dialog.placeholder")}
@@ -171,8 +176,10 @@ export const ProfileCreationModal = ({ open, onOpenChange, onCreate }: Props) =>
/> />
{mode === ManagementMode.SelfHosted && ( {mode === ManagementMode.SelfHosted && (
<Input <Input
id={urlId}
ref={urlRef} ref={urlRef}
autoFocus autoFocus
aria-label={t("settings.general.management.label")}
placeholder={t( placeholder={t(
"settings.general.management.urlPlaceholder", "settings.general.management.urlPlaceholder",
)} )}
@@ -124,7 +124,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
"data-[disabled=true]:opacity-50 data-[disabled=true]:pointer-events-none", "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"> <span className="truncate flex-1">
{t("profile.dropdown.manageProfiles")} {t("profile.dropdown.manageProfiles")}
</span> </span>
@@ -138,9 +138,17 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
}; };
const ProfileTriggerSkeleton = () => ( const ProfileTriggerSkeleton = () => (
<div className="h-10 flex items-center gap-2 px-3 rounded-lg select-none wails-no-draggable"> <div
<div className="size-4 rounded-full bg-nb-gray-900 animate-pulse shrink-0" /> role="status"
<div className="h-4 w-24 rounded bg-nb-gray-900 animate-pulse" /> 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> </div>
); );
@@ -150,11 +158,14 @@ type ProfileTriggerButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>( const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonProps>(
function ProfileTriggerButton({ name, className, ...props }, ref) { function ProfileTriggerButton({ name, className, ...props }, ref) {
const { t } = useTranslation();
const Icon = pickProfileIcon(name) ?? UserCircle; const Icon = pickProfileIcon(name) ?? UserCircle;
return ( return (
<button <button
ref={ref} ref={ref}
type="button" type="button"
aria-label={t("header.profile.switch")}
aria-haspopup="listbox"
className={cn( className={cn(
"h-10 flex items-center gap-2 px-3 rounded-lg outline-none cursor-default select-none wails-no-draggable", "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", "text-nb-gray-200 hover:bg-nb-gray-900",
@@ -165,11 +176,19 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
)} )}
{...props} {...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"}> <span className={"text-sm font-medium truncate max-w-[140px] wails-no-draggable"}>
{name} {name}
</span> </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> </button>
); );
}, },
@@ -199,7 +218,11 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
{showEmail && <TruncatedEmail email={profile.email} />} {showEmail && <TruncatedEmail email={profile.email} />}
</div> </div>
{isActive && ( {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> </Command.Item>
); );
@@ -128,7 +128,10 @@ export function ProfilesTab() {
"bg-nb-gray-930/60 border border-nb-gray-900 rounded-xl overflow-hidden", "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> <tbody>
{ordered.map((profile) => ( {ordered.map((profile) => (
<ProfileRow <ProfileRow
@@ -149,7 +152,11 @@ export function ProfilesTab() {
"flex flex-col items-center justify-center py-10 text-center" "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"}> <p className={"text-sm font-semibold text-nb-gray-200"}>
{t("settings.profiles.emptyTitle")} {t("settings.profiles.emptyTitle")}
</p> </p>
@@ -162,7 +169,7 @@ export function ProfilesTab() {
<SettingsBottomBar> <SettingsBottomBar>
<Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}> <Button variant={"primary"} size={"md"} onClick={() => setNewOpen(true)}>
<PlusCircle size={14} /> <PlusCircle size={14} aria-hidden="true" />
{t("settings.profiles.addProfile")} {t("settings.profiles.addProfile")}
</Button> </Button>
</SettingsBottomBar> </SettingsBottomBar>
@@ -201,6 +208,7 @@ const ProfileRow = ({ profile, isActive, onSwitch, onDeregister, onDelete }: Pro
> >
<Icon <Icon
size={15} size={15}
aria-hidden="true"
className={cn( className={cn(
"text-nb-gray-200 shrink-0", "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", "opacity-40 cursor-not-allowed hover:!text-nb-gray-400 hover:!bg-transparent",
)} )}
> >
<Icon size={16} /> <Icon size={16} aria-hidden="true" />
</button> </button>
); );
if (hidden) return button; if (hidden) return button;
@@ -153,11 +153,11 @@ export default function SessionExpirationDialog() {
}, []); }, []);
return ( return (
<ConfirmDialog ref={contentRef}> <ConfirmDialog ref={contentRef} aria-labelledby={"nb-session-expiration-title"}>
<SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} /> <SquareIcon icon={expired ? AlertCircleIcon : ClockIcon} />
<div className={"flex flex-col items-center gap-1"}> <div className={"flex flex-col items-center gap-1"}>
<DialogHeading> <DialogHeading id={"nb-session-expiration-title"}>
{expired ? t("sessionExpiration.expired") : activeTitle} {expired ? t("sessionExpiration.expired") : activeTitle}
</DialogHeading> </DialogHeading>
<DialogDescription> <DialogDescription>
@@ -7,12 +7,20 @@ import netbirdFull from "@/assets/logos/netbird-full.svg";
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons). // Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
const GithubIcon = (props: SVGProps<SVGSVGElement>) => ( const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}> <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> </svg>
); );
const SlackIcon = (props: SVGProps<SVGSVGElement>) => ( const SlackIcon = (props: SVGProps<SVGSVGElement>) => (
<svg viewBox={"0 0 24 24"} fill={"currentColor"} {...props}> <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> </svg>
); );
import { useSettings } from "@/contexts/SettingsContext.tsx"; 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)]" "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"}> <div className={"flex flex-col items-center gap-0.5 text-center"}>
<button <p
type={"button"}
className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"} className={"text-sm font-semibold text-nb-gray-100 cursor-text select-text"}
onClick={handleVersionClick} onClick={handleVersionClick}
> >
@@ -99,7 +106,7 @@ export function SettingsAbout() {
) : ( ) : (
t("settings.about.client", { version: daemonVersion }) t("settings.about.client", { version: daemonVersion })
)} )}
</button> </p>
<p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}> <p className={"text-sm text-nb-gray-250 cursor-text select-text font-medium"}>
{guiVersion === "development" ? ( {guiVersion === "development" ? (
<span> <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" "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> <span>{label}</span>
</button> </button>
))} ))}
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react"; import { useEffect, useId, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button"; import { Button } from "@/components/buttons/Button";
import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch";
@@ -21,6 +21,7 @@ export function SettingsGeneral() {
const { mdm, features } = useRestrictions(); const { mdm, features } = useRestrictions();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const managementUrlId = useId();
const prevMode = useRef(mode); const prevMode = useRef(mode);
useEffect(() => { useEffect(() => {
if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) { if (prevMode.current === ManagementMode.Cloud && mode === ManagementMode.SelfHosted) {
@@ -63,7 +64,9 @@ export function SettingsGeneral() {
<div> <div>
<div className={"flex items-start gap-3"}> <div className={"flex items-start gap-3"}>
<div className={"flex-1 min-w-0"}> <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> <HelpText>{t("settings.general.management.help")}</HelpText>
</div> </div>
<ManagementServerSwitch value={mode} onChange={setMode} /> <ManagementServerSwitch value={mode} onChange={setMode} />
@@ -71,6 +74,7 @@ export function SettingsGeneral() {
{mode === ManagementMode.SelfHosted && ( {mode === ManagementMode.SelfHosted && (
<div className={"flex items-start gap-3 mt-2"}> <div className={"flex items-start gap-3 mt-2"}>
<Input <Input
id={managementUrlId}
ref={inputRef} ref={inputRef}
value={displayUrl} value={displayUrl}
onChange={(e) => setUrl(e.target.value)} onChange={(e) => setUrl(e.target.value)}
@@ -29,7 +29,7 @@ export const SettingsNavigation = () => {
return ( return (
<div className={"flex flex-col w-52 shrink-0 items-center select-none"}> <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 <VerticalTabs.Trigger
value={"general"} value={"general"}
icon={SlidersHorizontalIcon} icon={SlidersHorizontalIcon}
@@ -6,12 +6,13 @@ import { Label } from "@/components/typography/Label";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
import { useSettings } from "@/contexts/SettingsContext.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() { export function SettingsSSH() {
const { t } = useTranslation(); const { t } = useTranslation();
const { config, setField } = useSettings(); const { config, setField } = useSettings();
const isSSHServerEnabled = config.serverSshAllowed; const isSSHServerEnabled = config.serverSshAllowed;
const jwtTtlId = useId();
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl)); const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
useEffect(() => { useEffect(() => {
@@ -97,11 +98,12 @@ export function SettingsSSH() {
)} )}
> >
<div className={"flex-1 max-w-md"}> <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> <HelpText margin={false}>{t("settings.ssh.jwtTtl.help")}</HelpText>
</div> </div>
<div className={"w-40 shrink-0"}> <div className={"w-40 shrink-0"}>
<Input <Input
id={jwtTtlId}
type={"number"} type={"number"}
min={0} min={0}
value={jwtTtlInput} value={jwtTtlInput}
@@ -10,7 +10,11 @@ export const SectionGroup = ({
children: ReactNode; children: ReactNode;
disabled?: boolean; 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"}> <h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
{title} {title}
</h2> </h2>
@@ -20,7 +24,7 @@ export const SectionGroup = ({
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => ( 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={"absolute bottom-0 left-0 w-full"}>
<div <div
className={ className={
@@ -1,4 +1,4 @@
import type { ReactNode } from "react"; import { useId, type ReactNode } from "react";
import { Trans, useTranslation } from "react-i18next"; import { Trans, useTranslation } from "react-i18next";
import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react"; import { CircleCheckBig, FolderOpen, Loader2 } from "lucide-react";
import { Browser } from "@wailsio/runtime"; import { Browser } from "@wailsio/runtime";
@@ -22,6 +22,7 @@ const SUPPORT_DOCS_URL = "https://docs.netbird.io/help/report-bug-issues";
export function SettingsTroubleshooting() { export function SettingsTroubleshooting() {
const { t } = useTranslation(); const { t } = useTranslation();
const durationId = useId();
const { const {
anonymize, anonymize,
setAnonymize, setAnonymize,
@@ -94,7 +95,7 @@ export function SettingsTroubleshooting() {
/> />
<div className={"flex items-center gap-6 justify-between"}> <div className={"flex items-center gap-6 justify-between"}>
<div className={"flex-1 max-w-md"}> <div className={"flex-1 max-w-md"}>
<Label as={"div"} disabled={!capture}> <Label htmlFor={durationId} disabled={!capture}>
{t("settings.troubleshooting.duration.label")} {t("settings.troubleshooting.duration.label")}
</Label> </Label>
<HelpText margin={false} disabled={!capture}> <HelpText margin={false} disabled={!capture}>
@@ -103,6 +104,7 @@ export function SettingsTroubleshooting() {
</div> </div>
<div className={"w-40 shrink-0"}> <div className={"w-40 shrink-0"}>
<Input <Input
id={durationId}
type={"number"} type={"number"}
min={1} min={1}
max={30} max={30}
@@ -244,6 +246,7 @@ function DoneResult({
<Input <Input
value={result.path} value={result.path}
readOnly readOnly
aria-label={t("settings.troubleshooting.done.savedTitle")}
customSuffix={ customSuffix={
<button <button
type={"button"} type={"button"}
@@ -251,7 +254,7 @@ function DoneResult({
className={"pointer-events-auto hover:text-white transition-all"} className={"pointer-events-auto hover:text-white transition-all"}
aria-label={t("settings.troubleshooting.done.openFileLocation")} aria-label={t("settings.troubleshooting.done.openFileLocation")}
> >
<FolderOpen size={16} /> <FolderOpen size={16} aria-hidden="true" />
</button> </button>
} }
/> />
@@ -259,6 +262,7 @@ function DoneResult({
{uploadFailed && ( {uploadFailed && (
<div <div
role="alert"
className={ className={
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300" "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"} className={"w-full"}
onClick={onRevealPath} onClick={onRevealPath}
> >
<FolderOpen size={14} /> <FolderOpen size={14} aria-hidden="true" />
{t("settings.troubleshooting.done.openFolder")} {t("settings.troubleshooting.done.openFolder")}
</Button> </Button>
) )
@@ -159,5 +159,12 @@ export default function WelcomeDialog() {
} }
}, [initial, step, handleTrayContinue, handleManagementContinue]); }, [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>
);
} }
@@ -81,7 +81,9 @@ export function WelcomeStepManagement({
return ( return (
<> <>
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}> <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"}> <DialogDescription align={"left"}>
{t("welcome.management.description")} {t("welcome.management.description")}
</DialogDescription> </DialogDescription>
@@ -35,7 +35,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
</div> </div>
<div className={"flex flex-col w-full gap-1"}> <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> <DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
</div> </div>
+36
View File
@@ -359,6 +359,42 @@
"header.menu.updateAvailable": { "header.menu.updateAvailable": {
"message": "Update verfügbar" "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": { "profile.switch.title": {
"message": "Zu Profil \"{name}\" wechseln?" "message": "Zu Profil \"{name}\" wechseln?"
}, },
+48
View File
@@ -479,6 +479,50 @@
"message": "Update Available", "message": "Update Available",
"description": "'More' menu item / badge shown when an update is 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": { "profile.switch.title": {
"message": "Switch Profile to \"{name}\"?", "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." "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", "message": "Debug Bundle Failed",
"description": "Error-dialog title when creating the debug bundle fails." "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": { "settings.tabs.general": {
"message": "General", "message": "General",
"description": "Settings tab label: General. Keep short." "description": "Settings tab label: General. Keep short."
+36
View File
@@ -359,6 +359,42 @@
"header.menu.updateAvailable": { "header.menu.updateAvailable": {
"message": "Actualización disponible" "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": { "profile.switch.title": {
"message": "¿Cambiar el perfil a «{name}»?" "message": "¿Cambiar el perfil a «{name}»?"
}, },
+36
View File
@@ -359,6 +359,42 @@
"header.menu.updateAvailable": { "header.menu.updateAvailable": {
"message": "Mise à jour disponible" "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": { "profile.switch.title": {
"message": "Basculer vers le profil « {name} » ?" "message": "Basculer vers le profil « {name} » ?"
}, },
+36
View File
@@ -359,6 +359,42 @@
"header.menu.updateAvailable": { "header.menu.updateAvailable": {
"message": "Frissítés elérhető" "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": { "profile.switch.title": {
"message": "Váltás a(z) \"{name}\" profilra?" "message": "Váltás a(z) \"{name}\" profilra?"
}, },
+36
View File
@@ -359,6 +359,42 @@
"header.menu.updateAvailable": { "header.menu.updateAvailable": {
"message": "Aggiornamento disponibile" "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": { "profile.switch.title": {
"message": "Passare al profilo «{name}»?" "message": "Passare al profilo «{name}»?"
}, },
+36
View File
@@ -359,6 +359,42 @@
"header.menu.updateAvailable": { "header.menu.updateAvailable": {
"message": "Atualização disponível" "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": { "profile.switch.title": {
"message": "Alternar perfil para \"{name}\"?" "message": "Alternar perfil para \"{name}\"?"
}, },
+36
View File
@@ -359,6 +359,42 @@
"header.menu.updateAvailable": { "header.menu.updateAvailable": {
"message": "Доступно обновление" "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": { "profile.switch.title": {
"message": "Переключиться на профиль «{name}»?" "message": "Переключиться на профиль «{name}»?"
}, },
+36
View File
@@ -359,6 +359,42 @@
"header.menu.updateAvailable": { "header.menu.updateAvailable": {
"message": "有可用更新" "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": { "profile.switch.title": {
"message": "切换到配置文件“{name}”?" "message": "切换到配置文件“{name}”?"
}, },