Files
netbird/client/ui/frontend/src/components/VerticalTabs.tsx
T
Eduard Gert 031a4bd70d [client] Migrate the desktop UI to Tailwind CSS 4 and fix frontend npm audit findings (#8277)
* [proxy] Fix npm audit findings in the web UI

npm audit reported 10 vulnerabilities (7 high), all in build tooling:
Vite/launch-editor, PostCSS, Babel, browserslist, js-yaml, nanoid,
brace-expansion and source-map-js. All are resolved by the lockfile
update; Vite is pinned exactly, so it is bumped from 7.3.2 to 7.3.7.

* [client] Migrate the desktop UI to Tailwind CSS 4 and fix npm audit findings

pnpm audit reported 36 vulnerabilities in the desktop UI. All but two
(braces and postcss-selector-parser, both pulled in by Tailwind 3 with
no patched version in range) are fixed by updating within the existing
ranges; react-router moves to 7.18.4, which also fixes an open redirect
and an XSS in its error handler.

The remaining two go away by moving to Tailwind CSS 4, which the proxy
web UI already uses:

- The config moves into an @theme block in globals.css; the official
  upgrade tool applied the v4 utility renames across the components.
- @tailwindcss/vite replaces the PostCSS plugin and autoprefixer.
- tw-animate-css replaces tailwindcss-animate with the same classes.
- The Prettier plugin reads the theme from the stylesheet.

A screenshot comparison of every screen against the Tailwind 3 build
found three regressions caused by v4's changed utility and variant
ordering, fixed here so the UI renders as before:

- Label combined inline-block and flex; v4 let inline-block win, which
  made every settings row taller. The dead inline-block is removed.
- The active settings tab lost its highlight in dark mode because
  dark: now outranks group-data-[state=active]:. The active color is
  set for dark mode explicitly.
- v4 rebuilt its default palette in OKLCH, shifting colors the theme
  does not override such as sky-400. The Tailwind 3 values for the
  neutral, zinc, sky, orange and amber families are pinned in @theme.

The only remaining visible difference is 2px less height per row in
the profiles list, because v4's preflight resets the browser's default
table-cell padding.
2026-10-21 15:53:05 +02:00

104 lines
3.6 KiB
TypeScript

import { type ComponentType, type ReactNode, forwardRef } from "react";
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}
/>
);
},
);
const List = forwardRef<HTMLDivElement, Tabs.TabsListProps>(function VerticalTabsList(
{ className, ...props },
ref,
) {
return (
<Tabs.List
ref={ref}
className={cn("flex w-full flex-col gap-1 p-5 pe-0", className)}
{...props}
/>
);
});
type TriggerProps = Tabs.TabsTriggerProps & {
icon: ComponentType<LucideProps>;
title: string;
iconSize?: number;
adornment?: ReactNode;
};
const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTabsTrigger(
{ icon: Icon, title, iconSize = 16, adornment, className, ...props },
ref,
) {
const isFocusVisible = useFocusVisible();
return (
<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-start outline-hidden 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",
isFocusVisible &&
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
className,
)}
{...props}
>
<Icon
size={iconSize}
aria-hidden={"true"}
className={cn(
"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 dark:group-data-[state=active]:text-nb-gray-100",
)}
/>
<span
className={cn(
"min-w-0 truncate text-sm font-medium transition-colors duration-150",
"text-nb-gray-350 dark:text-nb-gray-400",
"group-data-[state=active]:font-semibold group-data-[state=active]:text-nb-gray-100 dark:group-data-[state=active]:font-medium dark:group-data-[state=active]:text-nb-gray-100",
)}
>
{title}
</span>
{adornment && (
<div aria-hidden={"true"} className={"ms-auto me-2 shrink-0"}>
{adornment}
</div>
)}
</Tabs.Trigger>
);
});
const Content = forwardRef<HTMLDivElement, Tabs.TabsContentProps>(function VerticalTabsContent(
{ className, ...props },
ref,
) {
return (
<Tabs.Content
ref={ref}
tabIndex={-1}
className={cn("outline-hidden", className)}
{...props}
/>
);
});
export const VerticalTabs = Object.assign(Root, { List, Trigger, Content });