Files
netbird/client/ui/frontend/src/components/Tooltip.tsx
T
Eduard GertandEdward f5707c3485 [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>
2026-10-06 12:08:11 +02:00

108 lines
3.8 KiB
TypeScript

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;
children: ReactNode;
side?: RTooltip.TooltipContentProps["side"];
align?: RTooltip.TooltipContentProps["align"];
delayDuration?: number;
sideOffset?: number;
alignOffset?: number;
interactive?: boolean;
keepOpenOnClick?: boolean;
contentClassName?: string;
closeDelay?: number;
};
export const Tooltip = ({
content,
children,
side = "bottom",
align = "center",
delayDuration = 200,
sideOffset = 6,
alignOffset = 0,
interactive = false,
keepOpenOnClick = true,
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);
const cancelClose = () => {
if (closeTimer.current) {
clearTimeout(closeTimer.current);
closeTimer.current = null;
}
};
const scheduleClose = () => {
cancelClose();
if (closeDelay <= 0) {
setOpen(false);
return;
}
closeTimer.current = setTimeout(() => setOpen(false), closeDelay);
};
useEffect(() => () => cancelClose(), []);
const handleOpenChange = (next: boolean) => {
if (!next && keepOpenOnClick && hoveringRef.current) return;
if (next) cancelClose();
setOpen(next);
};
return (
<RTooltip.Provider delayDuration={delayDuration} disableHoverableContent={!interactive}>
<RTooltip.Root open={open} onOpenChange={handleOpenChange}>
<RTooltip.Trigger
asChild
onPointerEnter={() => {
hoveringRef.current = true;
cancelClose();
}}
onPointerLeave={() => {
hoveringRef.current = false;
scheduleClose();
}}
>
{children}
</RTooltip.Trigger>
<RTooltip.Portal>
<RTooltip.Content
side={physicalSide}
align={align}
sideOffset={sideOffset}
alignOffset={alignOffset}
onPointerEnter={interactive ? cancelClose : undefined}
onPointerLeave={interactive ? scheduleClose : undefined}
onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()}
className={cn(
"z-50 select-none text-xs text-nb-gray-100 shadow-sm dark:shadow-lg",
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
!interactive && "pointer-events-none",
contentClassName ??
"rounded-md border border-nb-gray-800 bg-white px-2 py-1 dark:border-nb-gray-850 dark:bg-nb-gray-900",
)}
>
{content}
</RTooltip.Content>
</RTooltip.Portal>
</RTooltip.Root>
</RTooltip.Provider>
);
};
function mirrorSide(side: Props["side"]): Props["side"] {
if (side === "left") return "right";
if (side === "right") return "left";
return side;
}