From f5707c348532a4b15ea39fb53264339f6045932c Mon Sep 17 00:00:00 2001 From: Eduard Gert Date: Tue, 6 Oct 2026 12:08:11 +0200 Subject: [PATCH] [client] Add RTL layout support to the desktop UI (#8076) * [client] Add RTL layout support to the desktop UI 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> --- .../src/components/CopyToClipboard.tsx | 6 ++-- .../frontend/src/components/DropdownMenu.tsx | 26 +++++++++------- .../src/components/LanguagePicker.tsx | 10 +++++-- client/ui/frontend/src/components/Tooltip.tsx | 11 ++++++- .../frontend/src/components/TruncatedText.tsx | 11 +++++-- .../frontend/src/components/VerticalTabs.tsx | 11 ++++--- .../src/components/dialog/ConfirmModal.tsx | 6 ++-- .../frontend/src/components/dialog/Dialog.tsx | 2 +- .../components/dialog/DialogDescription.tsx | 6 ++-- .../src/components/dialog/DialogHeading.tsx | 6 ++-- .../empty-state/DaemonOutdatedOverlay.tsx | 4 +-- .../frontend/src/components/inputs/Input.tsx | 19 ++++++------ .../frontend/src/components/inputs/Select.tsx | 2 +- .../components/switches/FancyToggleSwitch.tsx | 8 ++--- .../components/switches/SwitchItemGroup.tsx | 3 ++ .../src/components/switches/ToggleSwitch.tsx | 6 ++-- client/ui/frontend/src/globals.css | 5 ++++ client/ui/frontend/src/hooks/useDirection.ts | 6 ++++ .../ui/frontend/src/layouts/AppRightPanel.tsx | 7 +++-- client/ui/frontend/src/lib/i18n.ts | 1 + .../src/modules/error/ErrorDialog.tsx | 2 +- .../main/MainConnectionStatusSwitch.tsx | 9 +++--- .../src/modules/main/MainExitNodeSwitcher.tsx | 14 ++++++--- .../frontend/src/modules/main/MainHeader.tsx | 20 +++++++++---- .../ui/frontend/src/modules/main/MainPage.tsx | 4 +-- .../src/modules/main/advanced/Navigation.tsx | 13 +++++--- .../main/advanced/networks/NetworkFilters.tsx | 2 +- .../main/advanced/networks/Networks.tsx | 19 ++++++++---- .../main/advanced/peers/PeerDetailPanel.tsx | 30 +++++++++++++------ .../main/advanced/peers/PeerFilters.tsx | 2 +- .../src/modules/main/advanced/peers/Peers.tsx | 17 +++++++---- .../modules/profiles/ProfileCreationModal.tsx | 5 ++-- .../src/modules/profiles/ProfileDropdown.tsx | 5 +++- .../src/modules/profiles/ProfilesTab.tsx | 2 +- .../src/modules/settings/SettingsAbout.tsx | 4 +-- .../src/modules/settings/SettingsAdvanced.tsx | 2 ++ .../src/modules/settings/SettingsGeneral.tsx | 1 + .../src/modules/settings/SettingsPage.tsx | 3 ++ .../src/modules/settings/SettingsSection.tsx | 2 +- .../settings/SettingsTroubleshooting.tsx | 3 +- .../modules/welcome/WelcomeStepManagement.tsx | 7 +++-- .../src/modules/welcome/WelcomeStepTray.tsx | 4 +-- 42 files changed, 218 insertions(+), 108 deletions(-) create mode 100644 client/ui/frontend/src/hooks/useDirection.ts diff --git a/client/ui/frontend/src/components/CopyToClipboard.tsx b/client/ui/frontend/src/components/CopyToClipboard.tsx index 4af4ecc8f..b3d5a8715 100644 --- a/client/ui/frontend/src/components/CopyToClipboard.tsx +++ b/client/ui/frontend/src/components/CopyToClipboard.tsx @@ -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 = ({ ) => { + const dir = useDirection(); + return ; +}; const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; const DropdownMenuGroup = DropdownMenuPrimitive.Group; const DropdownMenuPortal = DropdownMenuPrimitive.Portal; @@ -32,16 +36,16 @@ const DropdownMenuSubTrigger = React.forwardRef< {children} - + )); DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName; @@ -102,9 +106,9 @@ const DropdownMenuItem = React.forwardRef< - + @@ -170,7 +174,7 @@ const DropdownMenuRadioItem = React.forwardRef< {children} @@ -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) => ( ); diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index d0a95906f..1e1fdb17b 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -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([]); const [open, setOpen] = useState(false); @@ -112,7 +114,7 @@ export function LanguagePicker() { aria-hidden={"true"} className={"shrink-0 text-nb-gray-200"} /> - + {current ? labelFor(current) : "—"} - + diff --git a/client/ui/frontend/src/components/Tooltip.tsx b/client/ui/frontend/src/components/Tooltip.tsx index d7a85277a..86589e47f 100644 --- a/client/ui/frontend/src/components/Tooltip.tsx +++ b/client/ui/frontend/src/components/Tooltip.tsx @@ -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 | null>(null); @@ -73,7 +76,7 @@ export const Tooltip = ({ ); }; + +function mirrorSide(side: Props["side"]): Props["side"] { + if (side === "left") return "right"; + if (side === "right") return "left"; + return side; +} diff --git a/client/ui/frontend/src/components/TruncatedText.tsx b/client/ui/frontend/src/components/TruncatedText.tsx index 5b2d2160c..1174a8b42 100644 --- a/client/ui/frontend/src/components/TruncatedText.tsx +++ b/client/ui/frontend/src/components/TruncatedText.tsx @@ -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(null); const [overflowing, setOverflowing] = useState(false); @@ -19,7 +26,7 @@ export const TruncatedText = ({ text, className, tooltipContent, delayDuration = }, [text]); const span = ( - + {text} ); diff --git a/client/ui/frontend/src/components/VerticalTabs.tsx b/client/ui/frontend/src/components/VerticalTabs.tsx index 306850ee2..bd1912662 100644 --- a/client/ui/frontend/src/components/VerticalTabs.tsx +++ b/client/ui/frontend/src/components/VerticalTabs.tsx @@ -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>( function VerticalTabsRoot({ className, ...props }, ref) { + const dir = useDirection(); return ( (function VerticalTab return ( ); @@ -46,7 +49,7 @@ const Trigger = forwardRef(function VerticalTab (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(function VerticalTab {title} {adornment && ( -
+
{adornment}
)} diff --git a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx index 7a8a1c906..e9c33e677 100644 --- a/client/ui/frontend/src/components/dialog/ConfirmModal.tsx +++ b/client/ui/frontend/src/components/dialog/ConfirmModal.tsx @@ -54,9 +54,9 @@ export const ConfirmModal = ({ onOpenAutoFocus={(e) => e.preventDefault()} >
-
- {title} - +
+ {title} + {description}
diff --git a/client/ui/frontend/src/components/dialog/Dialog.tsx b/client/ui/frontend/src/components/dialog/Dialog.tsx index c43c04b0b..3d0d47fc2 100644 --- a/client/ui/frontend/src/components/dialog/Dialog.tsx +++ b/client/ui/frontend/src/components/dialog/Dialog.tsx @@ -95,7 +95,7 @@ export const Content = forwardRef, Co {showClose && ( = { - left: "text-left", + start: "text-start", center: "text-center", - right: "text-right", + end: "text-end", }; type DialogDescriptionProps = { diff --git a/client/ui/frontend/src/components/dialog/DialogHeading.tsx b/client/ui/frontend/src/components/dialog/DialogHeading.tsx index b9dda72a9..52eb0c369 100644 --- a/client/ui/frontend/src/components/dialog/DialogHeading.tsx +++ b/client/ui/frontend/src/components/dialog/DialogHeading.tsx @@ -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 = { - left: "text-left", + start: "text-start", center: "text-center", - right: "text-right", + end: "text-end", }; type DialogHeadingProps = { diff --git a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx index e8e7108eb..da04bb4d7 100644 --- a/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx +++ b/client/ui/frontend/src/components/empty-state/DaemonOutdatedOverlay.tsx @@ -65,7 +65,7 @@ export const DaemonOutdatedOverlay = () => { {clientVersion === "development" ? ( {t("settings.about.clientName")}{" "} - + {t("settings.about.development")} @@ -77,7 +77,7 @@ export const DaemonOutdatedOverlay = () => { {guiVersion === "development" ? ( {t("settings.about.guiName")}{" "} - + {t("settings.about.development")} diff --git a/client/ui/frontend/src/components/inputs/Input.tsx b/client/ui/frontend/src/components/inputs/Input.tsx index eada79a1f..9d82f4dad 100644 --- a/client/ui/frontend/src/components/inputs/Input.tsx +++ b/client/ui/frontend/src/components/inputs/Input.tsx @@ -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({
@@ -138,7 +138,7 @@ function InputSuffixSlot({ return (
@@ -157,7 +157,7 @@ function NumberStepper({
(function Input( showPasswordToggle = false, copy = false, id, + dir, ...props }, ref, @@ -336,7 +337,7 @@ export const Input = forwardRef(function Input( return (
{label && } -
+
{customPrefix && ( ({ className={"shrink-0 text-nb-gray-200"} /> )} - {current?.label ?? "—"} + {current?.label ?? "—"}
@@ -55,7 +55,7 @@ export default function FancyToggleSwitch({
-
+
{helpText}
-
+
{ const layoutId = useId(); + const dir = useDirection(); const contextValue = useMemo(() => ({ value, layoutId }), [value, layoutId]); return ( { + const dir = useDirection(); + const parallaxX = dir === "rtl" ? 48 : -48; return (
{ function syncDocumentLang() { if (typeof document !== "undefined") { document.documentElement.lang = i18next.language; + document.documentElement.dir = i18next.dir(i18next.language); } } diff --git a/client/ui/frontend/src/modules/error/ErrorDialog.tsx b/client/ui/frontend/src/modules/error/ErrorDialog.tsx index 4fbb78052..0be13b86e 100644 --- a/client/ui/frontend/src/modules/error/ErrorDialog.tsx +++ b/client/ui/frontend/src/modules/error/ErrorDialog.tsx @@ -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")} > diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index 2f4014741..df48524c1 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -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", )} > @@ -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 && diff --git a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx index 0d6579dd7..3e6281352 100644 --- a/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx +++ b/client/ui/frontend/src/modules/main/MainExitNodeSwitcher.tsx @@ -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 = () => { handleSelect(NONE_VALUE)} /> {hasAny &&
} {hasAny && ( - + {exitNodes.map((n) => ( ( 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", )} diff --git a/client/ui/frontend/src/modules/main/MainHeader.tsx b/client/ui/frontend/src/modules/main/MainHeader.tsx index d79e76cd1..6aa5b7993 100644 --- a/client/ui/frontend/src/modules/main/MainHeader.tsx +++ b/client/ui/frontend/src/modules/main/MainHeader.tsx @@ -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 = () => { /> { { )} >
-
{profileSlot}
+
{profileSlot}
-
{settingsSlot}
+
+ {settingsSlot} +
); }; diff --git a/client/ui/frontend/src/modules/main/MainPage.tsx b/client/ui/frontend/src/modules/main/MainPage.tsx index c05b3a025..1d8f8d1f6 100644 --- a/client/ui/frontend/src/modules/main/MainPage.tsx +++ b/client/ui/frontend/src/modules/main/MainPage.tsx @@ -55,7 +55,7 @@ const MainBody = () => { > {!features.disableNetworks && ( -
+
)} @@ -79,7 +79,7 @@ const AdvancedAppRightPanel = () => { } overlayOpen={selected !== null} - className={"m-5 ml-0"} + className={"m-5 ms-0"} >
{ diff --git a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx index dc8d7505b..1f0f527d1 100644 --- a/client/ui/frontend/src/modules/main/advanced/Navigation.tsx +++ b/client/ui/frontend/src/modules/main/advanced/Navigation.tsx @@ -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) => { 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", diff --git a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx index 0d8c9f4db..3686275f0 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/NetworkFilters.tsx @@ -52,7 +52,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) => {active.label} ({counts[active.value]}) - + {filters.map((f) => { diff --git a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx index a13993aa9..da8028155 100644 --- a/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx +++ b/client/ui/frontend/src/modules/main/advanced/networks/Networks.tsx @@ -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 ? ( ) : ( - + {scrollParent && ( { { 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 ( { diff --git a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx index ee3de6643..e22054c68 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/PeerDetailPanel.tsx @@ -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(null); const backButtonRef = useRef(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", )} > - + {
- + @@ -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 }) => ( ( {label} { {active.label} ({counts[active.value]}) - + {filters.map((f) => { diff --git a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx index f3cb0f139..79a02a471 100644 --- a/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx +++ b/client/ui/frontend/src/modules/main/advanced/peers/Peers.tsx @@ -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 ? ( ) : ( - + {scrollParent && } @@ -167,6 +173,7 @@ type PeersListProps = { const PeersList = ({ data, scrollParent }: PeersListProps) => { const { setSelected } = usePeerDetail(); + const dir = useDirection(); const virtuosoRef = useRef(null); const rowRefs = useRef>(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( () => ({ 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 (
diff --git a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx index 19313ccb3..487080f3d 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileCreationModal.tsx @@ -175,7 +175,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }:
-
+
@@ -200,7 +200,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: {!managedManagementUrl && (
-
+
@@ -217,6 +217,7 @@ export const ProfileCreationModal = ({ open, onOpenChange, onSubmit, initial }: {mode === ManagementMode.SelfHosted && ( 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 && ( <> @@ -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", diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index f2110fda0..509f48061 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -440,7 +440,7 @@ const ProfileRow = ({ {showEmail && }
- + {t("settings.about.clientName")}{" "} - + {t("settings.about.development")} @@ -124,7 +124,7 @@ export function SettingsAbout() { {guiVersion === "development" ? ( {t("settings.about.guiName")}{" "} - + {t("settings.about.development")} diff --git a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx index 37b1932d9..6cb54d124 100644 --- a/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsAdvanced.tsx @@ -102,6 +102,7 @@ export function SettingsAdvanced() { setValues((v) => ({ ...v, interfaceName: e.target.value }))} @@ -151,6 +152,7 @@ export function SettingsAdvanced() { {t("settings.advanced.psk.help")} setUrl(e.target.value)} diff --git a/client/ui/frontend/src/modules/settings/SettingsPage.tsx b/client/ui/frontend/src/modules/settings/SettingsPage.tsx index bf0db3bec..9fb6e2129 100644 --- a/client/ui/frontend/src/modules/settings/SettingsPage.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsPage.tsx @@ -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 = { }; 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 = () => { ( <>
-
+
- {showKey && } + {showKey && } {result.path && !showKey && (
- + {t("welcome.management.title")} - + {t("welcome.management.description")}
@@ -103,9 +103,10 @@ export function WelcomeStepManagement({
{mode === ManagementMode.SelfHosted && ( -
+
setUrl(e.target.value)} diff --git a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx index 5a8b0d015..8838e5c92 100644 --- a/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx +++ b/client/ui/frontend/src/modules/welcome/WelcomeStepTray.tsx @@ -38,10 +38,10 @@ export function WelcomeStepTray({ onContinue }: Readonly)
- + {t(titleKey)} - {t(descriptionKey)} + {t(descriptionKey)}