[client] Add RTL layout support to the desktop UI (#8076)

* [client] Add RTL layout support to the desktop UI

<html dir> follows the active language, Radix primitives get the
matching dir, physical spacing/positioning uses logical utilities, and
directional icons, animations, arrow-key navigation and tooltip sides
flip in RTL.

* [client] Address RTL review feedback

Force LTR with isolation for monospace values, let truncated names
take their direction from their content, and keep the profile name
field in the UI direction.

* [client] Keep translated monospace labels in their own direction

The forced-LTR rule for monospace values now skips elements with an
explicit dir, translated development labels use dir=auto, and
monospace values rendered through TruncatedText opt into LTR.

---------

Co-authored-by: Edward <43848523+thomashacker@users.noreply.github.com>
This commit is contained in:
Eduard Gert
2026-10-06 12:08:11 +02:00
committed by GitHub
co-authored by Edward
parent a8dff998ef
commit f5707c3485
42 changed files with 218 additions and 108 deletions
@@ -80,7 +80,7 @@ export const CopyToClipboard = ({
aria-label={resolvedLabel}
aria-live={"polite"}
className={cn(
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none",
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-start outline-none",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
className,
)}
@@ -97,14 +97,14 @@ export const CopyToClipboard = ({
<span
aria-hidden={"true"}
className={
"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 absolute inset-x-0 bottom-0 border-b border-dashed border-transparent group-hover/copy:border-nb-gray-500"
}
/>
</span>
<span
aria-hidden={"true"}
className={cn(
"relative right-[1px] top-[2px] inline-flex shrink-0",
"relative end-[1px] top-[2px] inline-flex shrink-0",
iconAlignment === "left" ? "order-first" : "order-last",
iconClassName,
)}
@@ -3,8 +3,12 @@ import { cva } from "class-variance-authority";
import { Check, ChevronRight } from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/cn";
import { useDirection } from "@/hooks/useDirection";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenu = (props: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) => {
const dir = useDirection();
return <DropdownMenuPrimitive.Root dir={dir} {...props} />;
};
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
@@ -32,16 +36,16 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-3 pr-2 text-sm outline-none",
"relative flex cursor-default select-none items-center rounded-md py-1.5 pe-2 ps-3 text-sm outline-none",
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
inset && "ps-8",
menuItemVariants({ variant }),
className,
)}
{...props}
>
{children}
<ChevronRight className={"ml-auto h-4 w-4"} aria-hidden={"true"} />
<ChevronRight className={"ms-auto h-4 w-4 rtl:-scale-x-100"} aria-hidden={"true"} />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
@@ -102,9 +106,9 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-md py-1.5 pl-2 pr-2 text-sm outline-none",
"relative flex cursor-default select-none items-center rounded-md px-2 py-1.5 text-sm outline-none",
"transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
inset && "ps-8",
menuItemVariants({ variant }),
className,
)}
@@ -134,7 +138,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none",
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pe-2 ps-8 text-sm outline-none",
"text-nb-gray-200 transition-colors hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className,
@@ -142,7 +146,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
checked={checked}
{...props}
>
<span className={"absolute left-2 flex h-3.5 w-3.5 items-center justify-center"}>
<span className={"absolute start-2 flex h-3.5 w-3.5 items-center justify-center"}>
<DropdownMenuPrimitive.ItemIndicator>
<Check className={"h-4 w-4"} />
</DropdownMenuPrimitive.ItemIndicator>
@@ -170,7 +174,7 @@ const DropdownMenuRadioItem = React.forwardRef<
{children}
<span
aria-hidden={"true"}
className={"ml-auto flex w-4 shrink-0 items-center justify-center"}
className={"ms-auto flex w-4 shrink-0 items-center justify-center"}
>
<DropdownMenuPrimitive.ItemIndicator>
<Check size={14} className={"text-netbird"} />
@@ -190,7 +194,7 @@ const DropdownMenuLabel = React.forwardRef<
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold text-nb-gray-200",
inset && "pl-8",
inset && "ps-8",
className,
)}
{...props}
@@ -212,7 +216,7 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => (
<span
className={cn("ml-auto text-xs tracking-widest text-nb-gray-100 opacity-60", className)}
className={cn("ms-auto text-xs tracking-widest text-nb-gray-100 opacity-60", className)}
{...props}
/>
);
@@ -12,6 +12,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible";
import { loadLanguages } from "@/lib/i18n";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { useDirection } from "@/hooks/useDirection";
// No flag icons: flags represent countries, not languages. https://www.flagsarenotlanguages.com/blog/
@@ -21,6 +22,7 @@ const labelFor = (lang: Language): string =>
: lang.displayName;
export function LanguagePicker() {
const dir = useDirection();
const { t, i18n } = useTranslation();
const [languages, setLanguages] = useState<Language[]>([]);
const [open, setOpen] = useState(false);
@@ -112,7 +114,7 @@ export function LanguagePicker() {
aria-hidden={"true"}
className={"shrink-0 text-nb-gray-200"}
/>
<span className={"flex-1 truncate text-left"}>
<span className={"flex-1 truncate text-start"}>
{current ? labelFor(current) : "—"}
</span>
<ChevronDown
@@ -168,7 +170,11 @@ export function LanguagePicker() {
</div>
</div>
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"max-h-64 px-1"}>
<Command.List>
<Command.Empty>
+10 -1
View File
@@ -1,6 +1,7 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
import * as RTooltip from "@radix-ui/react-tooltip";
import { cn } from "@/lib/cn";
import { useDirection } from "@/hooks/useDirection";
type Props = {
content: ReactNode;
@@ -29,6 +30,8 @@ export const Tooltip = ({
contentClassName,
closeDelay = 0,
}: Props) => {
const dir = useDirection();
const physicalSide = dir === "rtl" ? mirrorSide(side) : side;
const [open, setOpen] = useState(false);
const hoveringRef = useRef(false);
const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -73,7 +76,7 @@ export const Tooltip = ({
</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content
side={side}
side={physicalSide}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
@@ -96,3 +99,9 @@ export const Tooltip = ({
</RTooltip.Provider>
);
};
function mirrorSide(side: Props["side"]): Props["side"] {
if (side === "left") return "right";
if (side === "right") return "left";
return side;
}
@@ -6,9 +6,16 @@ type Props = {
className?: string;
tooltipContent?: ReactNode;
delayDuration?: number;
dir?: "ltr" | "rtl" | "auto";
};
export const TruncatedText = ({ text, className, tooltipContent, delayDuration = 600 }: Props) => {
export const TruncatedText = ({
text,
className,
tooltipContent,
delayDuration = 600,
dir = "auto",
}: Props) => {
const ref = useRef<HTMLSpanElement>(null);
const [overflowing, setOverflowing] = useState(false);
@@ -19,7 +26,7 @@ export const TruncatedText = ({ text, className, tooltipContent, delayDuration =
}, [text]);
const span = (
<span ref={ref} className={className}>
<span ref={ref} dir={dir} className={className}>
{text}
</span>
);
@@ -3,12 +3,15 @@ import * as Tabs from "@radix-ui/react-tabs";
import { type LucideProps } from "lucide-react";
import { cn } from "@/lib/cn";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { useDirection } from "@/hooks/useDirection";
const Root = forwardRef<HTMLDivElement, Omit<Tabs.TabsProps, "orientation">>(
function VerticalTabsRoot({ className, ...props }, ref) {
const dir = useDirection();
return (
<Tabs.Root
ref={ref}
dir={dir}
orientation={"vertical"}
className={cn("flex min-h-0 flex-1", className)}
{...props}
@@ -24,7 +27,7 @@ const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTab
return (
<Tabs.List
ref={ref}
className={cn("flex w-full flex-col gap-1 p-5 pr-0", className)}
className={cn("flex w-full flex-col gap-1 p-5 pe-0", className)}
{...props}
/>
);
@@ -46,7 +49,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
<Tabs.Trigger
ref={ref}
className={cn(
"group flex w-full cursor-default items-center gap-3 rounded-md border border-transparent px-2 py-2.5 text-left outline-none dark:border-0",
"group flex w-full cursor-default items-center gap-3 rounded-md border border-transparent px-2 py-2.5 text-start outline-none dark:border-0",
"transition-colors duration-150",
"data-[state=active]:border-nb-gray-800 data-[state=active]:bg-white dark:data-[state=active]:bg-nb-gray-930",
"data-[state=inactive]:hover:bg-nb-gray-850 dark:data-[state=inactive]:hover:bg-nb-gray-935",
@@ -60,7 +63,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
size={iconSize}
aria-hidden={"true"}
className={cn(
"ml-2 shrink-0 transition-colors duration-150",
"ms-2 shrink-0 transition-colors duration-150",
"text-nb-gray-350 dark:text-nb-gray-400",
"group-data-[state=active]:text-nb-gray-100",
)}
@@ -75,7 +78,7 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
{title}
</span>
{adornment && (
<div aria-hidden={"true"} className={"ml-auto mr-2 shrink-0"}>
<div aria-hidden={"true"} className={"me-2 ms-auto shrink-0"}>
{adornment}
</div>
)}
@@ -54,9 +54,9 @@ export const ConfirmModal = ({
onOpenAutoFocus={(e) => e.preventDefault()}
>
<div className={"flex flex-col gap-5 px-5"}>
<div className={"flex flex-col gap-1 pl-1"}>
<DialogHeading align={"left"}>{title}</DialogHeading>
<DialogDescription align={"left"} className={"whitespace-pre-line"}>
<div className={"flex flex-col gap-1 ps-1"}>
<DialogHeading align={"start"}>{title}</DialogHeading>
<DialogDescription align={"start"} className={"whitespace-pre-line"}>
{description}
</DialogDescription>
</div>
@@ -95,7 +95,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
{showClose && (
<DialogPrimitive.Close
className={cn(
"absolute right-3 top-3 z-10 rounded-md p-3 transition-colors",
"absolute end-3 top-3 z-10 rounded-md p-3 transition-colors",
"text-nb-gray-300 hover:text-nb-gray-100",
"focus:outline-none disabled:pointer-events-none",
)}
@@ -1,12 +1,12 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogAlign = "left" | "center" | "right";
type DialogAlign = "start" | "center" | "end";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
start: "text-start",
center: "text-center",
right: "text-right",
end: "text-end",
};
type DialogDescriptionProps = {
@@ -1,12 +1,12 @@
import { type ReactNode } from "react";
import { cn } from "@/lib/cn";
type DialogAlign = "left" | "center" | "right";
type DialogAlign = "start" | "center" | "end";
const alignClass: Record<DialogAlign, string> = {
left: "text-left",
start: "text-start",
center: "text-center",
right: "text-right",
end: "text-end",
};
type DialogHeadingProps = {
@@ -65,7 +65,7 @@ export const DaemonOutdatedOverlay = () => {
{clientVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -77,7 +77,7 @@ export const DaemonOutdatedOverlay = () => {
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -86,13 +86,13 @@ function buildInputClassName(
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
"disabled:cursor-not-allowed disabled:opacity-40",
opts.hasCustomPrefix && "!rounded-l-none !border-l-0",
opts.hasSuffix && "!pr-9",
opts.hasIcon && "!pl-10",
opts.hasCustomPrefix && "!rounded-s-none !border-s-0",
opts.hasSuffix && "!pe-9",
opts.hasIcon && "!ps-10",
"border",
opts.readOnly && "!border-nb-gray-800 !bg-nb-gray-910 text-nb-gray-350",
opts.showStepper &&
"!rounded-r-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
"!rounded-e-none [-moz-appearance:textfield] [&::-webkit-inner-spin-button]:appearance-none [&::-webkit-outer-spin-button]:appearance-none",
opts.className,
);
}
@@ -107,7 +107,7 @@ function InputAffix({
<div
className={cn(
inputVariants({ prefixSuffixVariant: error ? "error" : "default" }),
"flex h-[40px] w-auto rounded-l-md bg-white px-3 py-2 text-sm",
"flex h-[40px] w-auto rounded-s-md bg-white px-3 py-2 text-sm",
"items-center whitespace-nowrap border",
disabled && "opacity-40",
className,
@@ -122,7 +122,7 @@ function InputIconSlot({ icon, disabled }: Readonly<{ icon: ReactNode; disabled?
return (
<div
className={cn(
"absolute left-0 top-0 flex h-full items-center pl-3 text-xs leading-[0] dark:text-nb-gray-300",
"absolute start-0 top-0 flex h-full items-center ps-3 text-xs leading-[0] dark:text-nb-gray-300",
disabled && "opacity-40",
)}
>
@@ -138,7 +138,7 @@ function InputSuffixSlot({
return (
<div
className={cn(
"pointer-events-none absolute right-0 top-0 flex h-full select-none items-center pr-3 text-xs leading-[0] dark:text-nb-gray-300",
"pointer-events-none absolute end-0 top-0 flex h-full select-none items-center pe-3 text-xs leading-[0] dark:text-nb-gray-300",
disabled && "opacity-30",
)}
>
@@ -157,7 +157,7 @@ function NumberStepper({
<div
className={cn(
"flex h-[40px] shrink-0 flex-col overflow-hidden",
"rounded-r-md border border-l-0",
"rounded-e-md border border-s-0",
"border-neutral-200 bg-white dark:border-nb-gray-700 dark:bg-nb-gray-900",
error && "dark:border-red-500",
disabled && "pointer-events-none opacity-40",
@@ -226,6 +226,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
showPasswordToggle = false,
copy = false,
id,
dir,
...props
},
ref,
@@ -336,7 +337,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
return (
<div className={"flex w-full min-w-0 flex-col"}>
{label && <Label htmlFor={inputId}>{label}</Label>}
<div className={cn("relative flex h-[40px] w-full", maxWidthClass)}>
<div dir={dir} className={cn("relative flex h-[40px] w-full", maxWidthClass)}>
{customPrefix && (
<InputAffix
content={customPrefix}
@@ -64,7 +64,7 @@ export function Select<T extends string>({
className={"shrink-0 text-nb-gray-200"}
/>
)}
<span className={"flex-1 truncate text-left"}>{current?.label ?? "—"}</span>
<span className={"flex-1 truncate text-start"}>{current?.label ?? "—"}</span>
<ChevronDown
size={12}
aria-hidden={"true"}
@@ -42,7 +42,7 @@ export default function FancyToggleSwitch({
role={"status"}
aria-busy={"true"}
aria-live={"polite"}
className={cn("inline-block w-full text-left", className)}
className={cn("inline-block w-full text-start", className)}
>
<div className={"flex justify-between gap-10"}>
<div className={cn(textWrapperClassName)}>
@@ -55,7 +55,7 @@ export default function FancyToggleSwitch({
</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<div className={"mt-2 pe-1"}>
<div
aria-hidden={"true"}
className={
@@ -73,7 +73,7 @@ export default function FancyToggleSwitch({
{...(disabled ? { inert: "" } : {})}
className={cn(
"relative z-[1] cursor-default transition-all duration-300",
"inline-block w-full text-left",
"inline-block w-full text-start",
disabled && "pointer-events-none opacity-30",
className,
)}
@@ -87,7 +87,7 @@ export default function FancyToggleSwitch({
<span id={descriptionId}>{helpText}</span>
</HelpText>
</div>
<div className={"mt-2 pr-1"}>
<div className={"mt-2 pe-1"}>
<ToggleSwitch
id={switchId}
checked={value}
@@ -1,6 +1,7 @@
import * as RadioGroup from "@radix-ui/react-radio-group";
import { createContext, type ReactNode, useContext, useId, useMemo } from "react";
import { cn } from "@/lib/cn";
import { useDirection } from "@/hooks/useDirection";
type SwitchItemGroupContextValue = {
value: string;
@@ -37,11 +38,13 @@ export const SwitchItemGroup = ({
"aria-labelledby": ariaLabelledBy,
}: Props) => {
const layoutId = useId();
const dir = useDirection();
const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]);
return (
<SwitchItemGroupContext.Provider value={contextValue}>
<RadioGroup.Root
dir={dir}
value={value}
onValueChange={onChange}
disabled={disabled}
@@ -36,9 +36,9 @@ const switchVariants = cva("", {
},
"thumb-size": {
default:
"h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0",
large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px]",
"h-5 w-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0 rtl:data-[state=checked]:-translate-x-5",
small: "h-[14px] w-[14px] data-[state=checked]:translate-x-[17px] data-[state=unchecked]:translate-x-0 rtl:data-[state=checked]:-translate-x-[17px]",
large: "h-[30px] w-[30px] data-[state=checked]:translate-x-[31px] data-[state=unchecked]:translate-x-[1px] rtl:data-[state=checked]:-translate-x-[31px] rtl:data-[state=unchecked]:-translate-x-[1px]",
},
},
});
+5
View File
@@ -96,6 +96,11 @@ body {
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
}
[dir="rtl"] :is(.font-mono, code, kbd):not([dir]) {
direction: ltr;
unicode-bidi: isolate;
}
.wails-draggable {
--wails-draggable: drag;
cursor: default;
@@ -0,0 +1,6 @@
import { useTranslation } from "react-i18next";
export function useDirection(): "ltr" | "rtl" {
const { i18n } = useTranslation();
return i18n.dir(i18n.language);
}
@@ -1,6 +1,7 @@
import { type ReactNode } from "react";
import { motion } from "framer-motion";
import { cn } from "@/lib/cn.ts";
import { useDirection } from "@/hooks/useDirection";
type Props = {
children: ReactNode;
@@ -15,17 +16,19 @@ const PANEL_TRANSITION = {
};
export const AppRightPanel = ({ children, overlay, overlayOpen = false, className }: Props) => {
const dir = useDirection();
const parallaxX = dir === "rtl" ? 48 : -48;
return (
<div
className={cn(
"wails-no-draggable relative m-5",
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-br-2xl",
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-ee-2xl",
className,
)}
>
<motion.div
animate={{ x: overlayOpen ? -48 : 0 }}
animate={{ x: overlayOpen ? parallaxX : 0 }}
transition={PANEL_TRANSITION}
className={"flex min-h-0 min-w-0 flex-1 flex-col"}
style={{ pointerEvents: overlayOpen ? "none" : "auto" }}
+1
View File
@@ -93,6 +93,7 @@ export async function initI18n(): Promise<void> {
function syncDocumentLang() {
if (typeof document !== "undefined") {
document.documentElement.lang = i18next.language;
document.documentElement.dir = i18next.dir(i18next.language);
}
}
@@ -66,7 +66,7 @@ export default function ErrorDialog() {
wrap
variant={"bright"}
className={
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-left"
"mt-2 w-full items-start gap-2 rounded-md bg-nb-gray-930 px-3 py-2 text-start"
}
aria-label={t("common.copy")}
>
@@ -267,12 +267,13 @@ export const MainConnectionStatusSwitch = () => {
tabIndex={show && fqdn ? 0 : -1}
className={cn(
"mt-1 max-h-[1em] min-h-[1em] max-w-full transition-opacity duration-300",
"relative left-[0.55rem]",
"relative start-[0.55rem]",
show && fqdn ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
<TruncatedText
text={shortenDns(fqdn) || " "}
dir={"ltr"}
className={
"block h-[18px] max-w-[310px] truncate font-mono text-[0.8rem] leading-tight text-nb-gray-300"
}
@@ -298,7 +299,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
tabIndex={show && ip ? 0 : -1}
className={cn(
"mt-1 max-h-[1em] min-h-[1em] transition-opacity duration-300",
"relative left-[0.55rem]",
"relative start-[0.55rem]",
show && ip ? "opacity-100" : "pointer-events-none opacity-0",
)}
>
@@ -345,7 +346,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
size={14}
aria-hidden={"true"}
className={cn(
"absolute -right-5 top-1/2 -translate-y-1/2",
"absolute -end-5 top-1/2 -translate-y-1/2",
"shrink-0 text-nb-gray-300 transition-colors",
"group-hover:text-nb-gray-200",
"group-data-[state=open]:text-nb-gray-200",
@@ -398,7 +399,7 @@ const IpRow = ({ value }: { value: string }) => {
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",
"rounded-md px-2 py-1.5 text-start",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
@@ -9,10 +9,12 @@ import { TruncatedText } from "@/components/TruncatedText";
import { useNetworks } from "@/contexts/NetworksContext";
import { useStatus } from "@/contexts/StatusContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { useDirection } from "@/hooks/useDirection";
const NONE_VALUE = "__none__";
export const MainExitNodeSwitcher = () => {
const dir = useDirection();
const { t } = useTranslation();
const { status } = useStatus();
const { exitNodes, toggleExitNode } = useNetworks();
@@ -101,7 +103,11 @@ export const MainExitNodeSwitcher = () => {
<NoneRow isActive={!active} onSelect={() => handleSelect(NONE_VALUE)} />
{hasAny && <div className={"-mx-1 my-1 h-px bg-nb-gray-910"} />}
{hasAny && (
<ScrollArea.Root type={"auto"} className={"-mx-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"max-h-72 px-1"}>
{exitNodes.map((n) => (
<ExitNodeRow
@@ -155,7 +161,7 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
tabIndex={0}
disabled={disabled}
className={cn(
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
"flex w-full items-center gap-3 rounded-xl p-2.5 pe-5 text-start outline-none",
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
"transition-colors duration-150",
"wails-no-draggable",
@@ -212,7 +218,7 @@ const NoneRow = ({ isActive, onSelect }: NoneRowProps) => {
value={NONE_VALUE}
onSelect={onSelect}
className={cn(
"flex items-center gap-2 px-2 py-2 pr-3",
"flex items-center gap-2 px-2 py-2 pe-3",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
)}
@@ -237,7 +243,7 @@ const ExitNodeRow = ({ id, label, isActive, onSelect }: ExitNodeRowProps) => (
value={id}
onSelect={onSelect}
className={cn(
"flex items-center gap-2 px-2 py-2 pr-3",
"flex items-center gap-2 px-2 py-2 pe-3",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
)}
@@ -25,7 +25,8 @@ import { cn } from "@/lib/cn";
import { formatShortcut, useKeyboardShortcut } from "@/hooks/useKeyboardShortcut";
import { useViewMode, type ViewMode } from "@/contexts/ViewModeContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { isWindows } from "@/lib/platform.ts";
import { isMacOS, isWindows } from "@/lib/platform.ts";
import { useDirection } from "@/hooks/useDirection";
const SETTINGS_SHORTCUT = { key: ",", cmd: true } as const;
@@ -35,6 +36,8 @@ export const MainHeader = () => {
const { viewMode, setViewMode } = useViewMode();
const { updateAvailable } = useClientVersion();
const { mdm, features } = useRestrictions();
const dir = useDirection();
const pinSettingsRight = isMacOS();
const openSettings = useCallback(() => {
setMenuOpen(false);
@@ -83,7 +86,7 @@ export const MainHeader = () => {
/>
</DropdownMenuTrigger>
<DropdownMenuContent
align={"end"}
align={pinSettingsRight && dir === "rtl" ? "start" : "end"}
sideOffset={8}
className={
"min-w-52 select-none data-[state=closed]:!animate-none data-[state=closed]:!duration-0"
@@ -138,7 +141,7 @@ export const MainHeader = () => {
<span
aria-hidden={"true"}
className={
"pointer-events-none absolute right-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
"pointer-events-none absolute end-1.5 top-1.5 flex h-2.5 w-2.5 items-center justify-center"
}
>
<span
@@ -168,10 +171,17 @@ export const MainHeader = () => {
)}
>
<div />
<div className={"ml-4 flex justify-center"}>{profileSlot}</div>
<div className={"ml-4 flex justify-center rtl:ml-1"}>{profileSlot}</div>
<div />
</div>
<div className={"absolute right-[1.3rem] top-1/2 -translate-y-1/2"}>{settingsSlot}</div>
<div
className={cn(
"absolute top-1/2 -translate-y-1/2",
pinSettingsRight ? "right-[1.3rem]" : "end-[1.3rem]",
)}
>
{settingsSlot}
</div>
</header>
);
};
@@ -55,7 +55,7 @@ const MainBody = () => {
>
<MainConnectionStatusSwitch />
{!features.disableNetworks && (
<div className={"wails-no-draggable absolute bottom-5 left-5 right-5"}>
<div className={"wails-no-draggable absolute inset-x-5 bottom-5"}>
<MainExitNodeSwitcher />
</div>
)}
@@ -79,7 +79,7 @@ const AdvancedAppRightPanel = () => {
<AppRightPanel
overlay={<PeerDetailPanel />}
overlayOpen={selected !== null}
className={"m-5 ml-0"}
className={"m-5 ms-0"}
>
<div
ref={(el) => {
@@ -5,6 +5,7 @@ import { cn } from "@/lib/cn";
import { useNavSection, type NavSection } from "@/contexts/NavSectionContext";
import { useStatus } from "@/contexts/StatusContext";
import { useRestrictions } from "@/contexts/RestrictionsContext";
import { useDirection } from "@/hooks/useDirection";
type TabEntry = {
value: NavSection;
@@ -48,6 +49,10 @@ export const Navigation = () => {
requestAnimationFrame(() => tabRefs.current[value]?.focus());
};
const dir = useDirection();
const forwardKey = dir === "rtl" ? "ArrowLeft" : "ArrowRight";
const backwardKey = dir === "rtl" ? "ArrowRight" : "ArrowLeft";
const handleKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
const enabled = tabs.filter((t) => isConnected || t.value === section);
if (enabled.length < 2) return;
@@ -55,10 +60,10 @@ export const Navigation = () => {
if (currentIndex === -1) return;
let nextIndex: number;
switch (e.key) {
case "ArrowRight":
case forwardKey:
nextIndex = (currentIndex + 1) % enabled.length;
break;
case "ArrowLeft":
case backwardKey:
nextIndex = (currentIndex - 1 + enabled.length) % enabled.length;
break;
case "Home":
@@ -106,8 +111,8 @@ export const Navigation = () => {
"group relative flex flex-1 items-center justify-center",
"gap-2.5 px-5 py-3.5",
"outline-none transition-all",
isFirst && "rounded-tl-xl",
isLast && "rounded-tr-xl",
isFirst && "rounded-ss-xl",
isLast && "rounded-se-xl",
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
@@ -52,7 +52,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
<ChevronDown size={14} aria-hidden={"true"} className={"ms-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
@@ -23,6 +23,7 @@ import { NoResults } from "@/components/empty-state/NoResults";
import { useStatus } from "@/contexts/StatusContext";
import { useNetworks } from "@/contexts/NetworksContext";
import { type NetworkFilter, NetworkFilters } from "./NetworkFilters";
import { useDirection } from "@/hooks/useDirection";
// Daemon renders DNS-route prefixes (zero netip.Prefix) as "invalid Prefix".
const INVALID_PREFIX = "invalid Prefix";
@@ -70,6 +71,7 @@ const buildOverlapMap = (
};
export const Networks = () => {
const dir = useDirection();
const { t } = useTranslation();
const { status } = useStatus();
const isConnected = status?.status === "Connected";
@@ -175,7 +177,11 @@ export const Networks = () => {
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && (
<NetworksList
@@ -343,7 +349,7 @@ const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: Netwo
return (
<div
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-9",
"group relative flex min-w-0 items-start gap-2.5 py-3 pe-9 ps-6",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
@@ -423,6 +429,7 @@ const Subtitle = ({ network, onKeyDown }: SubtitleProps) => {
<CopyToClipboard message={network.range} onKeyDown={onKeyDown}>
<TruncatedText
text={network.range}
dir={"ltr"}
className={
"block max-w-[300px] truncate font-mono text-xs text-nb-gray-400"
}
@@ -463,7 +470,7 @@ const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
contentClassName={cn(
"max-h-72 max-w-[18rem] overflow-auto",
"rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935",
"p-2 pr-4",
"p-2 pe-4",
)}
>
{span}
@@ -508,7 +515,9 @@ type ToggleProps = {
};
const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
const checkedTranslate = checked ? "translate-x-[1.125rem]" : "translate-x-0.5";
const checkedTranslate = checked
? "translate-x-[1.125rem] rtl:-translate-x-[1.125rem]"
: "translate-x-0.5 rtl:-translate-x-0.5";
return (
<span
className={cn(
@@ -521,7 +530,7 @@ const NetworkToggle = ({ checked, mixed }: ToggleProps) => {
<span
className={cn(
"inline-block h-4 w-4 rounded-full bg-white transition-transform",
mixed ? "translate-x-2.5" : checkedTranslate,
mixed ? "translate-x-2.5 rtl:-translate-x-2.5" : checkedTranslate,
)}
/>
</span>
@@ -43,6 +43,7 @@ import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { peerStatusLabelKey } from "./Peers";
import { useDirection } from "@/hooks/useDirection";
const DEFAULT_TRANSITION: Transition = {
duration: 0.32,
@@ -70,6 +71,8 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
const { t } = useTranslation();
const { selected, setSelected } = usePeerDetail();
const { status, refresh } = useStatus();
const dir = useDirection();
const offscreenX = dir === "rtl" ? "-100%" : "100%";
useEffect(() => {
if (!selected) return;
@@ -111,7 +114,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
setSelected(null);
return;
}
if (e.key === "ArrowLeft") {
if (e.key === (dir === "rtl" ? "ArrowRight" : "ArrowLeft")) {
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === "INPUT" || tag === "TEXTAREA" || target?.isContentEditable) return;
@@ -120,7 +123,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
};
globalThis.addEventListener("keydown", onKey);
return () => globalThis.removeEventListener("keydown", onKey);
}, [selected, setSelected]);
}, [selected, setSelected, dir]);
const dialogRef = useRef<HTMLDivElement>(null);
const backButtonRef = useRef<HTMLButtonElement>(null);
@@ -172,9 +175,9 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
aria-modal={"true"}
aria-labelledby={"nb-peer-detail-title"}
onKeyDown={onDialogKeyDown}
initial={{ x: "100%" }}
initial={{ x: offscreenX }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
exit={{ x: offscreenX }}
transition={transition}
style={{ willChange: "transform" }}
className={cn("absolute inset-0 z-20 flex flex-col", "bg-nb-gray-940")}
@@ -199,7 +202,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
"wails-no-draggable",
)}
>
<ArrowLeftIcon size={16} aria-hidden={"true"} />
<ArrowLeftIcon
size={16}
aria-hidden={"true"}
className={"rtl:-scale-x-100"}
/>
</button>
<Tooltip content={t(peerStatusLabelKey(selected.connStatus))} side={"top"}>
<span
@@ -248,7 +255,11 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
</button>
</Tooltip>
</div>
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport className={"h-full w-full"}>
<PeerDetails peer={selected} now={now} />
</ScrollArea.Viewport>
@@ -466,7 +477,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
"inline-flex shrink-0 items-center gap-1 rounded",
"bg-nb-gray-930 hover:bg-nb-gray-910/80 data-[state=open]:bg-nb-gray-910",
"border border-nb-gray-900",
"py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300",
"py-1 pe-2 ps-2.5 text-xs font-medium text-nb-gray-300",
"wails-no-draggable cursor-default outline-none transition-all",
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
)}
@@ -526,7 +537,7 @@ const ResourceRow = ({ value }: { value: string }) => {
aria-label={`${t("common.copy")} ${value}`}
className={cn(
"group/resourcerow 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-start",
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
"cursor-default outline-none transition-colors",
isFocusVisible &&
@@ -547,6 +558,7 @@ const ResourceRow = ({ value }: { value: string }) => {
const TruncatedRowValue = ({ value, mono }: { value: string; mono?: boolean }) => (
<TruncatedText
text={value}
dir={mono ? "ltr" : "auto"}
className={cn(
"inline-block min-w-0 max-w-[260px] truncate align-middle",
mono && "font-mono",
@@ -564,7 +576,7 @@ const Row = ({ icon: Icon, iconClassName, label, children }: RowProps) => (
<span className={"shrink-0 font-semibold text-nb-gray-200"}>{label}</span>
<span
className={cn(
"min-w-0 flex-1 pl-8 text-right",
"min-w-0 flex-1 ps-8 text-end",
"font-medium text-nb-gray-350",
"flex items-center justify-end",
)}
@@ -52,7 +52,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
<span>
{active.label} <span className={"tabular-nums"}>({counts[active.value]})</span>
</span>
<ChevronDown size={14} aria-hidden={"true"} className={"ml-0.5 shrink-0"} />
<ChevronDown size={14} aria-hidden={"true"} className={"ms-0.5 shrink-0"} />
</DropdownMenuTrigger>
<DropdownMenuContent align={"end"} className={"min-w-[10rem]"}>
{filters.map((f) => {
@@ -15,6 +15,7 @@ import { useStatus } from "@/contexts/StatusContext";
import { usePeerDetail } from "@/contexts/PeerDetailContext";
import { Tooltip } from "@/components/Tooltip";
import { TruncatedText } from "@/components/TruncatedText";
import { useDirection } from "@/hooks/useDirection";
import { PeerFilters, type StatusFilter } from "./PeerFilters";
const isOnline = (connStatus: string) => connStatus === "Connected";
@@ -42,6 +43,7 @@ export const peerStatusLabelKey = (connStatus: string): string => {
};
export const Peers = () => {
const dir = useDirection();
const { t } = useTranslation();
const { status } = useStatus();
const [search, setSearch] = useState("");
@@ -135,7 +137,11 @@ export const Peers = () => {
{filtered.length === 0 ? (
<NoResults />
) : (
<ScrollArea.Root type={"auto"} className={"min-h-0 flex-1 overflow-hidden"}>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
>
<ScrollArea.Viewport ref={setScrollParent} className={"h-full w-full"}>
{scrollParent && <PeersList data={filtered} scrollParent={scrollParent} />}
</ScrollArea.Viewport>
@@ -167,6 +173,7 @@ type PeersListProps = {
const PeersList = ({ data, scrollParent }: PeersListProps) => {
const { setSelected } = usePeerDetail();
const dir = useDirection();
const virtuosoRef = useRef<VirtuosoHandle>(null);
const rowRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
@@ -200,7 +207,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
e.preventDefault();
focusRow(Math.max(index - 1, 0));
break;
case "ArrowRight":
case dir === "rtl" ? "ArrowLeft" : "ArrowRight":
e.preventDefault();
setSelected(data[index]);
break;
@@ -223,7 +230,7 @@ const PeersList = ({ data, scrollParent }: PeersListProps) => {
const ctx = useMemo<PeerRowContext>(
() => ({ onKeyDown: handleRowKeyDown, onSelect: setSelected, setRowRef }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[data, setSelected],
[data, setSelected, dir],
);
return (
@@ -273,7 +280,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
return (
<div
className={cn(
"group relative flex min-w-0 items-start gap-2.5 py-3 pl-6 pr-4",
"group relative flex min-w-0 items-start gap-2.5 py-3 pe-4 ps-6",
"transition-colors hover:bg-nb-gray-900/40",
"wails-no-draggable",
)}
@@ -344,7 +351,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
size={16}
aria-hidden={"true"}
className={cn(
"pointer-events-none relative shrink-0 self-center text-nb-gray-300",
"pointer-events-none relative shrink-0 self-center text-nb-gray-300 rtl:-scale-x-100",
"opacity-0 transition-opacity group-hover:opacity-100",
)}
/>
@@ -175,7 +175,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
<form onSubmit={handleSubmit}>
<div className={"flex flex-col gap-6 px-7"}>
<div className={"flex flex-col gap-2"}>
<div className={"pl-1"}>
<div className={"ps-1"}>
<Label htmlFor={nameId} className={"mb-0.5"}>
{t("profile.dialog.nameLabel")}
</Label>
@@ -200,7 +200,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
{!managedManagementUrl && (
<div className={"flex flex-col gap-2"}>
<div className={"pl-1"}>
<div className={"ps-1"}>
<Label as={"div"} className={"mb-0.5"}>
{t("settings.general.management.label")}
</Label>
@@ -217,6 +217,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
{mode === ManagementMode.SelfHosted && (
<Input
id={urlId}
dir={"ltr"}
ref={urlRef}
aria-label={t("settings.general.management.label")}
placeholder={t(
@@ -11,6 +11,7 @@ import { useProfile } from "@/contexts/ProfileContext";
import { useFocusVisible } from "@/hooks/useFocusVisible";
import { cn } from "@/lib/cn";
import { errorDialog, formatErrorMessage } from "@/lib/errors";
import { useDirection } from "@/hooks/useDirection";
type ProfileDropdownProps = {
onManageProfiles?: () => void;
@@ -19,6 +20,7 @@ type ProfileDropdownProps = {
const MANAGE_VALUE = "__manage_profiles__";
export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
const dir = useDirection();
const { t } = useTranslation();
const { activeProfile, activeProfileId, profiles, switchProfile, loaded } = useProfile();
const [open, setOpen] = useState(false);
@@ -116,6 +118,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
{sortedProfiles.length > 0 && (
<>
<ScrollArea.Root
dir={dir}
type={"auto"}
className={"-mx-1 overflow-hidden"}
>
@@ -252,7 +255,7 @@ const ProfileRow = ({ profile, isActive, onSelect }: ProfileRowProps) => {
value={profile.id}
onSelect={() => onSelect(profile.id)}
className={cn(
"flex w-auto gap-2 px-2 py-2 pr-3 last:mb-1",
"flex w-auto gap-2 px-2 py-2 pe-3 last:mb-1",
"cursor-default rounded-md text-sm outline-none",
"data-[selected=true]:bg-nb-gray-900",
showEmail ? "items-start" : "items-center",
@@ -440,7 +440,7 @@ const ProfileRow = ({
{showEmail && <TruncatedEmail email={profile.email} />}
</div>
</td>
<td className={"shrink-0 text-right"}>
<td className={"shrink-0 text-end"}>
<RowActions
canSwitch={!isActive}
canDeregister={!!profile.email}
@@ -112,7 +112,7 @@ export function SettingsAbout() {
{daemonVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -124,7 +124,7 @@ export function SettingsAbout() {
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
<span dir={"auto"} className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
@@ -102,6 +102,7 @@ export function SettingsAdvanced() {
<SectionGroup title={t("settings.advanced.section.interface")}>
<Input
label={t("settings.advanced.interfaceName.label")}
dir={"ltr"}
value={values.interfaceName}
error={errors.interfaceName}
onChange={(e) => setValues((v) => ({ ...v, interfaceName: e.target.value }))}
@@ -151,6 +152,7 @@ export function SettingsAdvanced() {
<HelpText>{t("settings.advanced.psk.help")}</HelpText>
<Input
type={"password"}
dir={"ltr"}
showPasswordToggle={pskInputValue !== PSK_MASK}
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
value={pskInputValue}
@@ -88,6 +88,7 @@ export function SettingsGeneral() {
<div className={"mt-2 flex items-start gap-3"}>
<Input
id={managementUrlId}
dir={"ltr"}
ref={inputRef}
value={displayUrl}
onChange={(e) => setUrl(e.target.value)}
@@ -17,6 +17,7 @@ import { SettingsAdvanced } from "@/modules/settings/SettingsAdvanced.tsx";
import { SettingsTroubleshooting } from "@/modules/settings/SettingsTroubleshooting.tsx";
import { SettingsAbout } from "@/modules/settings/SettingsAbout.tsx";
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
import { useDirection } from "@/hooks/useDirection";
const EVENT_SETTINGS_OPEN = "netbird:settings:open";
@@ -43,6 +44,7 @@ const TAB_CONTENT: Record<Tab, ReactNode> = {
};
export const SettingsPage = () => {
const dir = useDirection();
const location = useLocation();
const navState = location.state as { tab?: string } | null;
const { mdm, features } = useRestrictions();
@@ -94,6 +96,7 @@ export const SettingsPage = () => {
<AutostartSettingsProvider>
<SettingsProvider>
<ScrollArea.Root
dir={dir}
key={active}
type={"auto"}
className={"min-h-0 flex-1 overflow-hidden"}
@@ -30,7 +30,7 @@ export const SectionGroup = ({
export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
<>
<div className={"h-[3.2rem] shrink-0"} aria-hidden={"true"} />
<div className={"absolute bottom-0 left-0 w-full"}>
<div className={"absolute bottom-0 start-0 w-full"}>
<div
className={
"flex w-full justify-end gap-3 border-t border-nb-gray-800 bg-nb-gray-940 px-8 py-5 dark:border-nb-gray-920"
@@ -298,10 +298,11 @@ function DoneResult({
</div>
<div className={"flex w-full max-w-sm flex-col gap-3"}>
{showKey && <Input value={result.uploadedKey} readOnly copy />}
{showKey && <Input dir={"ltr"} value={result.uploadedKey} readOnly copy />}
{result.path && !showKey && (
<Input
dir={"ltr"}
value={result.path}
readOnly
aria-label={t("settings.troubleshooting.done.savedTitle")}
@@ -90,10 +90,10 @@ export function WelcomeStepManagement({
return (
<>
<div className={cn("flex flex-col items-center gap-1", isMacOS() && "mt-4")}>
<DialogHeading id={"nb-welcome-management-title"} align={"left"}>
<DialogHeading id={"nb-welcome-management-title"} align={"start"}>
{t("welcome.management.title")}
</DialogHeading>
<DialogDescription align={"left"}>
<DialogDescription align={"start"}>
{t("welcome.management.description")}
</DialogDescription>
</div>
@@ -103,9 +103,10 @@ export function WelcomeStepManagement({
</div>
{mode === ManagementMode.SelfHosted && (
<div className={"wails-no-draggable w-full text-left"}>
<div className={"wails-no-draggable w-full text-start"}>
<Input
ref={inputRef}
dir={"ltr"}
placeholder={t("welcome.management.urlPlaceholder")}
value={url}
onChange={(e) => setUrl(e.target.value)}
@@ -38,10 +38,10 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
</div>
<div className={"flex w-full flex-col gap-1"}>
<DialogHeading id={"nb-welcome-title"} align={"left"}>
<DialogHeading id={"nb-welcome-title"} align={"start"}>
{t(titleKey)}
</DialogHeading>
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
<DialogDescription align={"start"}>{t(descriptionKey)}</DialogDescription>
</div>
<DialogActions>