mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-06 21:49:08 +02:00
[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:
@@ -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>
|
||||
|
||||
@@ -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]",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user