> = () => {
+ return (
+
+ );
+};
diff --git a/apps/web/modules/ui/components/pending-downgrade-banner/index.tsx b/apps/web/modules/ui/components/pending-downgrade-banner/index.tsx
index f23b2ac353..c83ad5c16d 100644
--- a/apps/web/modules/ui/components/pending-downgrade-banner/index.tsx
+++ b/apps/web/modules/ui/components/pending-downgrade-banner/index.tsx
@@ -4,12 +4,14 @@ import { useTranslate } from "@tolgee/react";
import { TriangleAlertIcon, XIcon } from "lucide-react";
import Link from "next/link";
import { useState } from "react";
+import { TUserLocale } from "@formbricks/types/user";
interface PendingDowngradeBannerProps {
lastChecked: Date;
active: boolean;
isPendingDowngrade: boolean;
environmentId: string;
+ locale: TUserLocale;
}
export const PendingDowngradeBanner = ({
@@ -17,6 +19,7 @@ export const PendingDowngradeBanner = ({
active,
isPendingDowngrade,
environmentId,
+ locale,
}: PendingDowngradeBannerProps) => {
const threeDaysInMillis = 3 * 24 * 60 * 60 * 1000;
const { t } = useTranslate();
@@ -25,7 +28,11 @@ export const PendingDowngradeBanner = ({
: false;
const scheduledDowngradeDate = new Date(lastChecked.getTime() + threeDaysInMillis);
- const formattedDate = `${scheduledDowngradeDate.getMonth() + 1}/${scheduledDowngradeDate.getDate()}/${scheduledDowngradeDate.getFullYear()}`;
+ const formattedDate = scheduledDowngradeDate.toLocaleDateString(locale, {
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
const [show, setShow] = useState(true);
@@ -47,8 +54,7 @@ export const PendingDowngradeBanner = ({
{t(
"common.we_were_unable_to_verify_your_license_because_the_license_server_is_unreachable"
- )}
- .{" "}
+ )}{" "}
{isLastCheckedWithin72Hours
? t("common.you_will_be_downgraded_to_the_community_edition_on_date", {
date: formattedDate,
diff --git a/apps/web/modules/ui/components/select/index.tsx b/apps/web/modules/ui/components/select/index.tsx
index 06bca2bff4..66deff275c 100644
--- a/apps/web/modules/ui/components/select/index.tsx
+++ b/apps/web/modules/ui/components/select/index.tsx
@@ -18,7 +18,7 @@ const SelectTrigger = React.forwardRef<
diff --git a/apps/web/modules/ui/components/separator/index.test.tsx b/apps/web/modules/ui/components/separator/index.test.tsx
new file mode 100644
index 0000000000..a9ca7b41bf
--- /dev/null
+++ b/apps/web/modules/ui/components/separator/index.test.tsx
@@ -0,0 +1,219 @@
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import { Separator } from ".";
+
+// Mock Radix UI Separator component
+vi.mock("@radix-ui/react-separator", () => {
+ const Root = vi.fn(({ className, orientation, decorative, ...props }) => (
+
+ )) as any;
+ Root.displayName = "SeparatorRoot";
+
+ return {
+ Root,
+ };
+});
+
+describe("Separator Component", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("renders correctly with default props", () => {
+ render();
+
+ const separator = screen.getByTestId("separator-root");
+ expect(separator).toBeInTheDocument();
+ expect(separator).toHaveAttribute("data-orientation", "horizontal");
+ expect(separator).toHaveAttribute("data-decorative", "true");
+ });
+
+ test("applies correct default classes for horizontal orientation", () => {
+ render();
+
+ const separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveClass("bg-border");
+ expect(separator).toHaveClass("shrink-0");
+ expect(separator).toHaveClass("h-[1px]");
+ expect(separator).toHaveClass("w-full");
+ });
+
+ test("applies correct classes for vertical orientation", () => {
+ render();
+
+ const separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveAttribute("data-orientation", "vertical");
+ expect(separator).toHaveClass("bg-border");
+ expect(separator).toHaveClass("shrink-0");
+ expect(separator).toHaveClass("h-full");
+ expect(separator).toHaveClass("w-[1px]");
+ });
+
+ test("handles custom className correctly", () => {
+ render();
+
+ const separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveClass("custom-separator");
+ expect(separator).toHaveClass("bg-border");
+ expect(separator).toHaveClass("shrink-0");
+ });
+
+ test("forwards decorative prop correctly", () => {
+ const { rerender } = render();
+
+ let separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveAttribute("data-decorative", "false");
+
+ rerender();
+ separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveAttribute("data-decorative", "true");
+ });
+
+ test("uses default decorative value when not provided", () => {
+ render();
+
+ const separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveAttribute("data-decorative", "true");
+ });
+
+ test("uses default orientation value when not provided", () => {
+ render();
+
+ const separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveAttribute("data-orientation", "horizontal");
+ });
+
+ test("forwards additional props correctly", () => {
+ render();
+
+ const separator = screen.getByTestId("custom-separator");
+ expect(separator).toHaveAttribute("data-testid", "custom-separator");
+ expect(separator).toHaveAttribute("aria-label", "Custom separator");
+ expect(separator).toHaveAttribute("role", "separator");
+ });
+
+ test("ref forwarding works correctly", () => {
+ const ref = vi.fn();
+ render();
+
+ expect(ref).toHaveBeenCalled();
+ });
+
+ test("combines orientation and custom className correctly", () => {
+ const { rerender } = render();
+
+ let separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveClass("my-separator");
+ expect(separator).toHaveClass("h-[1px]");
+ expect(separator).toHaveClass("w-full");
+
+ rerender();
+ separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveClass("my-separator");
+ expect(separator).toHaveClass("h-full");
+ expect(separator).toHaveClass("w-[1px]");
+ });
+
+ test("applies all base classes regardless of orientation", () => {
+ const { rerender } = render();
+
+ let separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveClass("bg-border");
+ expect(separator).toHaveClass("shrink-0");
+
+ rerender();
+ separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveClass("bg-border");
+ expect(separator).toHaveClass("shrink-0");
+ });
+
+ test("handles undefined className gracefully", () => {
+ render();
+
+ const separator = screen.getByTestId("separator-root");
+ expect(separator).toBeInTheDocument();
+ expect(separator).toHaveClass("bg-border");
+ expect(separator).toHaveClass("shrink-0");
+ });
+
+ test("handles empty className gracefully", () => {
+ render();
+
+ const separator = screen.getByTestId("separator-root");
+ expect(separator).toBeInTheDocument();
+ expect(separator).toHaveClass("bg-border");
+ expect(separator).toHaveClass("shrink-0");
+ });
+
+ test("handles multiple custom classes", () => {
+ render();
+
+ const separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveClass("class1");
+ expect(separator).toHaveClass("class2");
+ expect(separator).toHaveClass("class3");
+ expect(separator).toHaveClass("bg-border");
+ expect(separator).toHaveClass("shrink-0");
+ });
+
+ test("export is available", () => {
+ expect(Separator).toBeDefined();
+ expect(typeof Separator).toBe("object"); // forwardRef returns an object
+ });
+
+ test("component has correct displayName", () => {
+ expect(Separator.displayName).toBe(SeparatorPrimitive.Root.displayName);
+ });
+
+ test("renders with all props combined", () => {
+ render(
+
+ );
+
+ const separator = screen.getByTestId("full-separator");
+ expect(separator).toBeInTheDocument();
+ expect(separator).toHaveAttribute("data-orientation", "vertical");
+ expect(separator).toHaveAttribute("data-decorative", "false");
+ expect(separator).toHaveAttribute("data-testid", "full-separator");
+ expect(separator).toHaveAttribute("aria-label", "Vertical separator");
+ expect(separator).toHaveClass("custom-class");
+ expect(separator).toHaveClass("bg-border");
+ expect(separator).toHaveClass("shrink-0");
+ expect(separator).toHaveClass("h-full");
+ expect(separator).toHaveClass("w-[1px]");
+ });
+
+ test("orientation prop type checking - accepts valid values", () => {
+ const { rerender } = render();
+ let separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveAttribute("data-orientation", "horizontal");
+
+ rerender();
+ separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveAttribute("data-orientation", "vertical");
+ });
+
+ test("decorative prop type checking - accepts boolean values", () => {
+ const { rerender } = render();
+ let separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveAttribute("data-decorative", "true");
+
+ rerender();
+ separator = screen.getByTestId("separator-root");
+ expect(separator).toHaveAttribute("data-decorative", "false");
+ });
+});
diff --git a/apps/web/modules/ui/components/separator/index.tsx b/apps/web/modules/ui/components/separator/index.tsx
new file mode 100644
index 0000000000..88bdc8ea71
--- /dev/null
+++ b/apps/web/modules/ui/components/separator/index.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import { cn } from "@/modules/ui/lib/utils";
+import * as SeparatorPrimitive from "@radix-ui/react-separator";
+import * as React from "react";
+
+const Separator = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, orientation = "horizontal", decorative = true, ...props }, ref) => (
+
+));
+Separator.displayName = SeparatorPrimitive.Root.displayName;
+
+export { Separator };
diff --git a/apps/web/modules/ui/components/sheet/index.test.tsx b/apps/web/modules/ui/components/sheet/index.test.tsx
new file mode 100644
index 0000000000..ee2c93541e
--- /dev/null
+++ b/apps/web/modules/ui/components/sheet/index.test.tsx
@@ -0,0 +1,514 @@
+import * as SheetPrimitive from "@radix-ui/react-dialog";
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, describe, expect, test, vi } from "vitest";
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetOverlay,
+ SheetPortal,
+ SheetTitle,
+ SheetTrigger,
+} from ".";
+
+// Mock Radix UI Dialog components (Sheet uses Dialog primitives)
+vi.mock("@radix-ui/react-dialog", () => {
+ const Root = vi.fn(({ children }) => {children}
) as any;
+ Root.displayName = "SheetRoot";
+
+ const Trigger = vi.fn(({ children }) => ) as any;
+ Trigger.displayName = "SheetTrigger";
+
+ const Portal = vi.fn(({ children }) => {children}
) as any;
+ Portal.displayName = "SheetPortal";
+
+ const Overlay = vi.fn(({ className, ...props }) => (
+
+ )) as any;
+ Overlay.displayName = "SheetOverlay";
+
+ const Content = vi.fn(({ className, children, ...props }) => (
+
+ {children}
+
+ )) as any;
+ Content.displayName = "SheetContent";
+
+ const Close = vi.fn(({ className, children }) => (
+
+ )) as any;
+ Close.displayName = "SheetClose";
+
+ const Title = vi.fn(({ className, children, ...props }) => (
+
+ {children}
+
+ )) as any;
+ Title.displayName = "SheetTitle";
+
+ const Description = vi.fn(({ className, children, ...props }) => (
+
+ {children}
+
+ )) as any;
+ Description.displayName = "SheetDescription";
+
+ return {
+ Root,
+ Trigger,
+ Portal,
+ Overlay,
+ Content,
+ Close,
+ Title,
+ Description,
+ };
+});
+
+// Mock Lucide React
+vi.mock("lucide-react", () => ({
+ XIcon: ({ className }: { className?: string }) => (
+
+ X Icon
+
+ ),
+}));
+
+describe("Sheet Components", () => {
+ afterEach(() => {
+ cleanup();
+ });
+
+ test("Sheet renders correctly", () => {
+ render(
+
+ Sheet Content
+
+ );
+
+ expect(screen.getByTestId("sheet-root")).toBeInTheDocument();
+ expect(screen.getByText("Sheet Content")).toBeInTheDocument();
+ });
+
+ test("SheetTrigger renders correctly", () => {
+ render(
+
+ Open Sheet
+
+ );
+
+ expect(screen.getByTestId("sheet-trigger")).toBeInTheDocument();
+ expect(screen.getByText("Open Sheet")).toBeInTheDocument();
+ });
+
+ test("SheetClose renders correctly", () => {
+ render(
+
+ Close Sheet
+
+ );
+
+ expect(screen.getByTestId("sheet-close")).toBeInTheDocument();
+ expect(screen.getByText("Close Sheet")).toBeInTheDocument();
+ });
+
+ test("SheetPortal renders correctly", () => {
+ render(
+
+ Portal Content
+
+ );
+
+ expect(screen.getByTestId("sheet-portal")).toBeInTheDocument();
+ expect(screen.getByText("Portal Content")).toBeInTheDocument();
+ });
+
+ test("SheetOverlay renders with correct classes", () => {
+ render();
+
+ const overlay = screen.getByTestId("sheet-overlay");
+ expect(overlay).toBeInTheDocument();
+ expect(overlay).toHaveClass("test-class");
+ expect(overlay).toHaveClass("fixed");
+ expect(overlay).toHaveClass("inset-0");
+ expect(overlay).toHaveClass("z-50");
+ expect(overlay).toHaveClass("bg-black/80");
+ });
+
+ test("SheetContent renders with default variant (right)", () => {
+ render(
+
+ Test Content
+
+ );
+
+ expect(screen.getByTestId("sheet-portal")).toBeInTheDocument();
+ expect(screen.getByTestId("sheet-overlay")).toBeInTheDocument();
+ expect(screen.getByTestId("sheet-content")).toBeInTheDocument();
+ expect(screen.getByTestId("sheet-close")).toBeInTheDocument();
+ expect(screen.getByTestId("x-icon")).toBeInTheDocument();
+ expect(screen.getByText("Test Content")).toBeInTheDocument();
+ expect(screen.getByText("Close")).toBeInTheDocument();
+ });
+
+ test("SheetContent applies correct variant classes", () => {
+ const { rerender } = render(
+
+ Top Content
+
+ );
+
+ let content = screen.getByTestId("sheet-content");
+ expect(content).toHaveClass("inset-x-0");
+ expect(content).toHaveClass("top-0");
+ expect(content).toHaveClass("border-b");
+ expect(content).toHaveClass("data-[state=closed]:slide-out-to-top");
+ expect(content).toHaveClass("data-[state=open]:slide-in-from-top");
+
+ rerender(
+
+ Bottom Content
+
+ );
+
+ content = screen.getByTestId("sheet-content");
+ expect(content).toHaveClass("inset-x-0");
+ expect(content).toHaveClass("bottom-0");
+ expect(content).toHaveClass("border-t");
+ expect(content).toHaveClass("data-[state=closed]:slide-out-to-bottom");
+ expect(content).toHaveClass("data-[state=open]:slide-in-from-bottom");
+
+ rerender(
+
+ Left Content
+
+ );
+
+ content = screen.getByTestId("sheet-content");
+ expect(content).toHaveClass("inset-y-0");
+ expect(content).toHaveClass("left-0");
+ expect(content).toHaveClass("h-full");
+ expect(content).toHaveClass("w-3/4");
+ expect(content).toHaveClass("border-r");
+ expect(content).toHaveClass("data-[state=closed]:slide-out-to-left");
+ expect(content).toHaveClass("data-[state=open]:slide-in-from-left");
+ expect(content).toHaveClass("sm:max-w-sm");
+
+ rerender(
+
+ Right Content
+
+ );
+
+ content = screen.getByTestId("sheet-content");
+ expect(content).toHaveClass("inset-y-0");
+ expect(content).toHaveClass("right-0");
+ expect(content).toHaveClass("h-full");
+ expect(content).toHaveClass("w-3/4");
+ expect(content).toHaveClass("border-l");
+ expect(content).toHaveClass("data-[state=closed]:slide-out-to-right");
+ expect(content).toHaveClass("data-[state=open]:slide-in-from-right");
+ expect(content).toHaveClass("sm:max-w-sm");
+ });
+
+ test("SheetContent applies custom className", () => {
+ render(
+
+ Custom Content
+
+ );
+
+ const content = screen.getByTestId("sheet-content");
+ expect(content).toHaveClass("custom-class");
+ });
+
+ test("SheetContent has correct base classes", () => {
+ render(
+
+ Base Content
+
+ );
+
+ const content = screen.getByTestId("sheet-content");
+ expect(content).toHaveClass("fixed");
+ expect(content).toHaveClass("z-50");
+ expect(content).toHaveClass("gap-4");
+ expect(content).toHaveClass("bg-background");
+ expect(content).toHaveClass("p-6");
+ expect(content).toHaveClass("shadow-lg");
+ expect(content).toHaveClass("transition");
+ expect(content).toHaveClass("ease-in-out");
+ expect(content).toHaveClass("data-[state=closed]:duration-300");
+ expect(content).toHaveClass("data-[state=open]:duration-500");
+ });
+
+ test("SheetContent close button has correct styling", () => {
+ render(
+
+ Content
+
+ );
+
+ const closeButton = screen.getByTestId("sheet-close");
+ expect(closeButton).toHaveClass("ring-offset-background");
+ expect(closeButton).toHaveClass("focus:ring-ring");
+ expect(closeButton).toHaveClass("data-[state=open]:bg-secondary");
+ expect(closeButton).toHaveClass("absolute");
+ expect(closeButton).toHaveClass("right-4");
+ expect(closeButton).toHaveClass("top-4");
+ expect(closeButton).toHaveClass("rounded-sm");
+ expect(closeButton).toHaveClass("opacity-70");
+ expect(closeButton).toHaveClass("transition-opacity");
+ expect(closeButton).toHaveClass("hover:opacity-100");
+ });
+
+ test("SheetContent close button icon has correct styling", () => {
+ render(
+
+ Content
+
+ );
+
+ const icon = screen.getByTestId("x-icon");
+ expect(icon).toBeInTheDocument();
+ expect(icon).toHaveClass("h-4");
+ expect(icon).toHaveClass("w-4");
+ });
+
+ test("SheetHeader renders correctly", () => {
+ render(
+
+ Header Content
+
+ );
+
+ const header = screen.getByText("Header Content").parentElement;
+ expect(header).toBeInTheDocument();
+ expect(header).toHaveClass("test-class");
+ expect(header).toHaveClass("flex");
+ expect(header).toHaveClass("flex-col");
+ expect(header).toHaveClass("space-y-2");
+ expect(header).toHaveClass("text-center");
+ expect(header).toHaveClass("sm:text-left");
+ });
+
+ test("SheetFooter renders correctly", () => {
+ render(
+
+
+
+ );
+
+ const footer = screen.getByText("OK").parentElement;
+ expect(footer).toBeInTheDocument();
+ expect(footer).toHaveClass("test-class");
+ expect(footer).toHaveClass("flex");
+ expect(footer).toHaveClass("flex-col-reverse");
+ expect(footer).toHaveClass("sm:flex-row");
+ expect(footer).toHaveClass("sm:justify-end");
+ expect(footer).toHaveClass("sm:space-x-2");
+ });
+
+ test("SheetTitle renders correctly", () => {
+ render(Sheet Title);
+
+ const title = screen.getByTestId("sheet-title");
+ expect(title).toBeInTheDocument();
+ expect(title).toHaveClass("test-class");
+ expect(title).toHaveClass("text-foreground");
+ expect(title).toHaveClass("text-lg");
+ expect(title).toHaveClass("font-semibold");
+ expect(screen.getByText("Sheet Title")).toBeInTheDocument();
+ });
+
+ test("SheetDescription renders correctly", () => {
+ render(Sheet Description);
+
+ const description = screen.getByTestId("sheet-description");
+ expect(description).toBeInTheDocument();
+ expect(description).toHaveClass("test-class");
+ expect(description).toHaveClass("text-muted-foreground");
+ expect(description).toHaveClass("text-sm");
+ expect(screen.getByText("Sheet Description")).toBeInTheDocument();
+ });
+
+ test("SheetContent forwards props correctly", () => {
+ render(
+
+ Custom Content
+
+ );
+
+ const content = screen.getByTestId("custom-sheet");
+ expect(content).toHaveAttribute("aria-label", "Custom Sheet");
+ });
+
+ test("SheetTitle forwards props correctly", () => {
+ render(Custom Title);
+
+ const title = screen.getByTestId("custom-title");
+ expect(title).toHaveAttribute("data-testid", "custom-title");
+ });
+
+ test("SheetDescription forwards props correctly", () => {
+ render(Custom Description);
+
+ const description = screen.getByTestId("custom-description");
+ expect(description).toHaveAttribute("data-testid", "custom-description");
+ });
+
+ test("SheetHeader forwards props correctly", () => {
+ render(
+
+ Header
+
+ );
+
+ const header = screen.getByText("Header").parentElement;
+ expect(header).toHaveAttribute("data-testid", "custom-header");
+ });
+
+ test("SheetFooter forwards props correctly", () => {
+ render(
+
+
+
+ );
+
+ const footer = screen.getByText("Footer").parentElement;
+ expect(footer).toHaveAttribute("data-testid", "custom-footer");
+ });
+
+ test("SheetHeader handles dangerouslySetInnerHTML", () => {
+ const htmlContent = "Dangerous HTML";
+ render();
+
+ const header = document.querySelector(".flex.flex-col.space-y-2");
+ expect(header).toBeInTheDocument();
+ expect(header?.innerHTML).toContain(htmlContent);
+ });
+
+ test("SheetFooter handles dangerouslySetInnerHTML", () => {
+ const htmlContent = "Dangerous Footer HTML";
+ render();
+
+ const footer = document.querySelector(".flex.flex-col-reverse");
+ expect(footer).toBeInTheDocument();
+ expect(footer?.innerHTML).toContain(htmlContent);
+ });
+
+ test("All components export correctly", () => {
+ expect(Sheet).toBeDefined();
+ expect(SheetTrigger).toBeDefined();
+ expect(SheetClose).toBeDefined();
+ expect(SheetPortal).toBeDefined();
+ expect(SheetOverlay).toBeDefined();
+ expect(SheetContent).toBeDefined();
+ expect(SheetHeader).toBeDefined();
+ expect(SheetFooter).toBeDefined();
+ expect(SheetTitle).toBeDefined();
+ expect(SheetDescription).toBeDefined();
+ });
+
+ test("Components have correct displayName", () => {
+ expect(SheetOverlay.displayName).toBe(SheetPrimitive.Overlay.displayName);
+ expect(SheetContent.displayName).toBe(SheetPrimitive.Content.displayName);
+ expect(SheetTitle.displayName).toBe(SheetPrimitive.Title.displayName);
+ expect(SheetDescription.displayName).toBe(SheetPrimitive.Description.displayName);
+ expect(SheetHeader.displayName).toBe("SheetHeader");
+ expect(SheetFooter.displayName).toBe("SheetFooter");
+ });
+
+ test("Close button has accessibility attributes", () => {
+ render(
+
+ Content
+
+ );
+
+ const closeButton = screen.getByTestId("sheet-close");
+ expect(closeButton).toHaveClass("focus:outline-none");
+ expect(closeButton).toHaveClass("focus:ring-2");
+ expect(closeButton).toHaveClass("focus:ring-offset-2");
+ expect(closeButton).toHaveClass("disabled:pointer-events-none");
+
+ // Check for screen reader text
+ expect(screen.getByText("Close")).toBeInTheDocument();
+ expect(screen.getByText("Close")).toHaveClass("sr-only");
+ });
+
+ test("SheetContent ref forwarding works", () => {
+ const ref = vi.fn();
+ render(
+
+ Content
+
+ );
+
+ expect(ref).toHaveBeenCalled();
+ });
+
+ test("SheetTitle ref forwarding works", () => {
+ const ref = vi.fn();
+ render(Title);
+
+ expect(ref).toHaveBeenCalled();
+ });
+
+ test("SheetDescription ref forwarding works", () => {
+ const ref = vi.fn();
+ render(Description);
+
+ expect(ref).toHaveBeenCalled();
+ });
+
+ test("SheetOverlay ref forwarding works", () => {
+ const ref = vi.fn();
+ render();
+
+ expect(ref).toHaveBeenCalled();
+ });
+
+ test("Full sheet example renders correctly", () => {
+ render(
+
+
+ Open Sheet
+
+
+
+ Sheet Title
+ Sheet Description
+
+ Sheet Body Content
+
+
+
+
+
+
+ );
+
+ expect(screen.getByTestId("sheet-root")).toBeInTheDocument();
+ expect(screen.getByTestId("sheet-trigger")).toBeInTheDocument();
+ expect(screen.getByTestId("sheet-portal")).toBeInTheDocument();
+ expect(screen.getByTestId("sheet-overlay")).toBeInTheDocument();
+ expect(screen.getByTestId("sheet-content")).toBeInTheDocument();
+ expect(screen.getByTestId("sheet-close")).toBeInTheDocument();
+ expect(screen.getByTestId("sheet-title")).toBeInTheDocument();
+ expect(screen.getByTestId("sheet-description")).toBeInTheDocument();
+ expect(screen.getByText("Open Sheet")).toBeInTheDocument();
+ expect(screen.getByText("Sheet Title")).toBeInTheDocument();
+ expect(screen.getByText("Sheet Description")).toBeInTheDocument();
+ expect(screen.getByText("Sheet Body Content")).toBeInTheDocument();
+ expect(screen.getByText("Cancel")).toBeInTheDocument();
+ expect(screen.getByText("Submit")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/modules/ui/components/sheet/index.tsx b/apps/web/modules/ui/components/sheet/index.tsx
new file mode 100644
index 0000000000..387a2816c1
--- /dev/null
+++ b/apps/web/modules/ui/components/sheet/index.tsx
@@ -0,0 +1,119 @@
+"use client";
+
+import { cn } from "@/modules/ui/lib/utils";
+import * as SheetPrimitive from "@radix-ui/react-dialog";
+import { type VariantProps, cva } from "class-variance-authority";
+import { XIcon } from "lucide-react";
+import * as React from "react";
+
+const Sheet = SheetPrimitive.Root;
+
+const SheetTrigger = SheetPrimitive.Trigger;
+
+const SheetClose = SheetPrimitive.Close;
+
+const SheetPortal = SheetPrimitive.Portal;
+
+const SheetOverlay = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
+
+const sheetVariants = cva(
+ "fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",
+ {
+ variants: {
+ side: {
+ top: "inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
+ bottom:
+ "inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
+ left: "inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
+ right:
+ "inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
+ },
+ },
+ defaultVariants: {
+ side: "right",
+ },
+ }
+);
+
+interface SheetContentProps
+ extends React.ComponentPropsWithoutRef,
+ VariantProps {}
+
+const SheetContent = React.forwardRef, SheetContentProps>(
+ ({ side = "right", className, children, ...props }, ref) => (
+
+
+
+
+
+ Close
+
+ {children}
+
+
+ )
+);
+SheetContent.displayName = SheetPrimitive.Content.displayName;
+
+const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+SheetHeader.displayName = "SheetHeader";
+
+const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => (
+
+);
+SheetFooter.displayName = "SheetFooter";
+
+const SheetTitle = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetTitle.displayName = SheetPrimitive.Title.displayName;
+
+const SheetDescription = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+));
+SheetDescription.displayName = SheetPrimitive.Description.displayName;
+
+export {
+ Sheet,
+ SheetPortal,
+ SheetOverlay,
+ SheetTrigger,
+ SheetClose,
+ SheetContent,
+ SheetHeader,
+ SheetFooter,
+ SheetTitle,
+ SheetDescription,
+};
diff --git a/apps/web/modules/ui/components/sidebar/index.test.tsx b/apps/web/modules/ui/components/sidebar/index.test.tsx
new file mode 100644
index 0000000000..0c6223007f
--- /dev/null
+++ b/apps/web/modules/ui/components/sidebar/index.test.tsx
@@ -0,0 +1,586 @@
+import "@testing-library/jest-dom/vitest";
+import { cleanup, fireEvent, render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import * as React from "react";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+} from "./index";
+
+// Mock the useIsMobile hook - this is already mocked in vitestSetup.ts
+vi.mock("@/modules/ui/hooks/use-mobile", () => ({
+ useIsMobile: vi.fn().mockReturnValue(false),
+}));
+
+// Mock Button component
+vi.mock("@/modules/ui/components/button", () => {
+ const MockButton = React.forwardRef(({ children, onClick, ...props }, ref) => (
+
+ ));
+ MockButton.displayName = "MockButton";
+
+ return {
+ Button: MockButton,
+ };
+});
+
+// Mock Input component
+vi.mock("@/modules/ui/components/input", () => {
+ const MockInput = React.forwardRef((props, ref) => );
+ MockInput.displayName = "MockInput";
+
+ return {
+ Input: MockInput,
+ };
+});
+
+// Mock Separator component
+vi.mock("@/modules/ui/components/separator", () => {
+ const MockSeparator = React.forwardRef((props, ref) => (
+
+ ));
+ MockSeparator.displayName = "MockSeparator";
+
+ return {
+ Separator: MockSeparator,
+ };
+});
+
+// Mock Sheet components
+vi.mock("@/modules/ui/components/sheet", () => ({
+ Sheet: ({ children, open, onOpenChange }: any) => (
+ onOpenChange?.(!open)}>
+ {children}
+
+ ),
+ SheetContent: ({ children, side, ...props }: any) => (
+
+ {children}
+
+ ),
+ SheetHeader: ({ children }: any) => {children}
,
+ SheetTitle: ({ children }: any) => {children}
,
+ SheetDescription: ({ children }: any) => {children}
,
+}));
+
+// Mock Skeleton component
+vi.mock("@/modules/ui/components/skeleton", () => ({
+ Skeleton: ({ className, style, ...props }: any) => (
+
+ ),
+}));
+
+// Mock Tooltip components
+vi.mock("@/modules/ui/components/tooltip", () => ({
+ Tooltip: ({ children }: any) => {children}
,
+ TooltipContent: ({ children, hidden, ...props }: any) => (
+
+ {children}
+
+ ),
+ TooltipProvider: ({ children }: any) => {children}
,
+ TooltipTrigger: ({ children }: any) => {children}
,
+}));
+
+// Mock Slot from @radix-ui/react-slot
+vi.mock("@radix-ui/react-slot", () => {
+ const MockSlot = React.forwardRef(({ children, ...props }, ref) => (
+
+ {children}
+
+ ));
+ MockSlot.displayName = "MockSlot";
+
+ return {
+ Slot: MockSlot,
+ };
+});
+
+// Mock Lucide icons
+vi.mock("lucide-react", () => ({
+ Columns2Icon: () => ,
+}));
+
+// Mock cn utility
+vi.mock("@/modules/ui/lib/utils", () => ({
+ cn: (...args: any[]) => args.filter(Boolean).flat().join(" "),
+}));
+
+// Test component that uses useSidebar hook
+const TestComponent = () => {
+ const sidebar = useSidebar();
+ return (
+
+
{sidebar?.state || "unknown"}
+
{sidebar?.open?.toString() || "unknown"}
+
{sidebar?.isMobile?.toString() || "unknown"}
+
{sidebar?.openMobile?.toString() || "unknown"}
+
+
+
+
+ );
+};
+
+describe("Sidebar Components", () => {
+ beforeEach(() => {
+ // Reset document.cookie
+ Object.defineProperty(document, "cookie", {
+ writable: true,
+ value: "",
+ });
+
+ // Mock addEventListener and removeEventListener
+ global.addEventListener = vi.fn();
+ global.removeEventListener = vi.fn();
+
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ describe("Core Functionality", () => {
+ test("useSidebar hook throws error when used outside provider", () => {
+ const TestComponentWithoutProvider = () => {
+ useSidebar();
+ return Test
;
+ };
+
+ expect(() => render()).toThrow(
+ "useSidebar must be used within a SidebarProvider."
+ );
+ });
+
+ test("SidebarProvider manages state and provides context correctly", async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+
+ // Test with default state
+ const { rerender } = render(
+
+
+
+ );
+
+ expect(screen.getByTestId("sidebar-state")).toHaveTextContent("expanded");
+ expect(screen.getByTestId("sidebar-open")).toHaveTextContent("true");
+
+ // Test toggle functionality
+ await user.click(screen.getByTestId("toggle-button"));
+ expect(document.cookie).toContain("sidebar_state=false");
+
+ // Test with controlled state
+ rerender(
+
+
+
+ );
+
+ expect(screen.getByTestId("sidebar-open")).toHaveTextContent("false");
+ await user.click(screen.getByTestId("set-open-button"));
+ expect(onOpenChange).toHaveBeenCalledWith(true);
+
+ // Test mobile functionality
+ await user.click(screen.getByTestId("set-open-mobile-button"));
+ expect(screen.getByTestId("sidebar-open-mobile")).toHaveTextContent("true");
+ });
+
+ test("SidebarProvider handles keyboard shortcuts and cleanup", () => {
+ const preventDefault = vi.fn();
+
+ const { unmount } = render(
+
+
+
+ );
+
+ // Test keyboard shortcut registration
+ expect(global.addEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
+
+ // Test keyboard shortcut handling
+ const [[, eventHandler]] = vi.mocked(global.addEventListener).mock.calls;
+
+ // Valid shortcut
+ (eventHandler as (event: any) => void)({
+ key: "b",
+ ctrlKey: true,
+ preventDefault,
+ });
+ expect(preventDefault).toHaveBeenCalled();
+
+ // Invalid shortcut
+ preventDefault.mockClear();
+ (eventHandler as (event: any) => void)({
+ key: "a",
+ ctrlKey: true,
+ preventDefault,
+ });
+ expect(preventDefault).not.toHaveBeenCalled();
+
+ // Test cleanup
+ unmount();
+ expect(global.removeEventListener).toHaveBeenCalledWith("keydown", expect.any(Function));
+ });
+ });
+
+ describe("Interactive Components", () => {
+ test("SidebarTrigger and SidebarRail toggle sidebar functionality", async () => {
+ const user = userEvent.setup();
+ const customOnClick = vi.fn();
+
+ render(
+
+
+
+
+
+ );
+
+ const trigger = screen.getByTestId("columns2-icon").closest("button");
+ expect(trigger).not.toBeNull();
+ await user.click(trigger as HTMLButtonElement);
+ expect(customOnClick).toHaveBeenCalled();
+ expect(screen.getByTestId("sidebar-state")).toHaveTextContent("collapsed");
+
+ // Test SidebarRail
+ const rail = screen.getByLabelText("Toggle Sidebar");
+ expect(rail).toHaveAttribute("aria-label", "Toggle Sidebar");
+ await user.click(rail);
+ expect(screen.getByTestId("sidebar-state")).toHaveTextContent("expanded");
+ });
+
+ test("Sidebar renders with different configurations", () => {
+ const { rerender } = render(
+
+
+ Sidebar Content
+
+
+ );
+
+ expect(screen.getByText("Sidebar Content")).toBeInTheDocument();
+
+ // Test different variants
+ rerender(
+
+
+ Sidebar Content
+
+
+ );
+
+ expect(screen.getByText("Sidebar Content")).toBeInTheDocument();
+ });
+ });
+
+ describe("Layout Components", () => {
+ test("basic layout components render correctly with custom classes", () => {
+ const layoutComponents = [
+ { Component: SidebarInset, content: "Main Content", selector: "main" },
+ { Component: SidebarInput, content: null, selector: "input", props: { placeholder: "Search..." } },
+ { Component: SidebarHeader, content: "Header Content", selector: '[data-sidebar="header"]' },
+ { Component: SidebarFooter, content: "Footer Content", selector: '[data-sidebar="footer"]' },
+ { Component: SidebarSeparator, content: null, selector: '[role="separator"]' },
+ { Component: SidebarContent, content: "Content", selector: '[data-sidebar="content"]' },
+ ];
+
+ layoutComponents.forEach(({ Component, content, selector, props = {} }) => {
+ const testProps = { className: "custom-class", ...props };
+
+ render(
+
+ {content && {content}
}
+
+ );
+
+ if (content) {
+ expect(screen.getByText(content)).toBeInTheDocument();
+ const element = screen.getByText(content).closest(selector);
+ expect(element).toHaveClass("custom-class");
+ } else if (selector === "input") {
+ expect(screen.getByRole("textbox")).toHaveClass("custom-class");
+ } else {
+ expect(screen.getByRole("separator")).toHaveClass("custom-class");
+ }
+
+ cleanup();
+ });
+ });
+ });
+
+ describe("Group Components", () => {
+ test("sidebar group components render and handle interactions", async () => {
+ const user = userEvent.setup();
+
+ render(
+
+
+ Group Label
+
+ Action
+
+
+ Group Content
+
+
+
+ );
+
+ // Test all components render
+ expect(screen.getByText("Group Label")).toBeInTheDocument();
+ expect(screen.getByText("Group Content")).toBeInTheDocument();
+
+ // Test action button
+ const actionButton = screen.getByRole("button");
+ expect(actionButton).toBeInTheDocument();
+ await user.click(actionButton);
+
+ // Test custom classes
+ expect(screen.getByText("Group Label")).toHaveClass("label-class");
+ expect(screen.getByText("Group Content").closest('[data-sidebar="group-content"]')).toHaveClass(
+ "content-class"
+ );
+ expect(actionButton).toHaveClass("action-class");
+ });
+
+ test("sidebar group components handle asChild prop", () => {
+ render(
+
+
+ Group Label
+
+
+
+
+
+ );
+
+ expect(screen.getByText("Group Label")).toBeInTheDocument();
+ expect(screen.getByText("Action")).toBeInTheDocument();
+ });
+ });
+
+ describe("Menu Components", () => {
+ test("basic menu components render with custom classes", () => {
+ render(
+
+
+
+ Menu Item
+
+
+ 5
+
+ );
+
+ expect(screen.getByText("Menu Item")).toBeInTheDocument();
+ expect(screen.getByText("5")).toBeInTheDocument();
+
+ const menu = screen.getByText("Menu Item").closest("ul");
+ const menuItem = screen.getByText("Menu Item").closest("li");
+
+ expect(menu).toHaveClass("menu-class");
+ expect(menuItem).toHaveClass("item-class");
+ expect(screen.getByText("5")).toHaveClass("badge-class");
+ });
+
+ test("SidebarMenuButton handles all variants and interactions", async () => {
+ const { rerender } = render(
+
+
+ Menu Button
+
+
+ );
+
+ const button = screen.getByText("Menu Button").closest("button");
+ expect(button).toHaveAttribute("data-active", "true");
+ expect(button).toHaveAttribute("data-size", "sm");
+ expect(button).toHaveClass("button-class");
+ expect(screen.getByTestId("tooltip")).toBeInTheDocument();
+
+ // Test tooltip object
+ rerender(
+
+
+ Menu Button
+
+
+ );
+
+ expect(screen.getByTestId("tooltip-content")).toBeInTheDocument();
+
+ // Test asChild
+ rerender(
+
+
+ Menu Button
+
+
+ );
+
+ expect(screen.getByText("Menu Button")).toBeInTheDocument();
+ });
+
+ test("SidebarMenuAction handles showOnHover and asChild", () => {
+ const { rerender } = render(
+
+
+ Action
+
+
+ );
+
+ expect(screen.getByText("Action")).toBeInTheDocument();
+
+ rerender(
+
+
+
+
+
+ );
+
+ expect(screen.getByText("Action")).toBeInTheDocument();
+ });
+
+ test("SidebarMenuSkeleton renders with icon option", () => {
+ const { rerender } = render(
+
+
+
+ );
+
+ expect(screen.getByTestId("skeleton")).toBeInTheDocument();
+
+ const skeleton = screen.getAllByTestId("skeleton")[0].parentElement;
+ expect(skeleton).toHaveClass("skeleton-class");
+
+ rerender(
+
+
+
+ );
+
+ expect(screen.getAllByTestId("skeleton")).toHaveLength(2);
+ });
+ });
+
+ describe("Sub Menu Components", () => {
+ test("sub menu components render and handle all props", () => {
+ const { rerender } = render(
+
+
+
+
+ Sub Button
+
+
+
+
+ );
+
+ expect(screen.getByText("Sub Button")).toBeInTheDocument();
+
+ const subMenu = screen.getByText("Sub Button").closest("ul");
+ const subButton = screen.getByText("Sub Button").closest("a");
+
+ expect(subMenu).toHaveClass("sub-menu-class");
+ expect(subButton).toHaveAttribute("data-active", "true");
+ expect(subButton).toHaveAttribute("data-size", "sm");
+ expect(subButton).toHaveClass("sub-button-class");
+
+ // Test asChild
+ rerender(
+
+
+
+
+
+ );
+
+ expect(screen.getByText("Sub Button")).toBeInTheDocument();
+ });
+ });
+
+ describe("Provider Configuration", () => {
+ test("SidebarProvider handles custom props and styling", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId("sidebar-state")).toHaveTextContent("collapsed");
+ expect(screen.getByTestId("sidebar-open")).toHaveTextContent("false");
+
+ const wrapper = screen.getByText("collapsed").closest(".group\\/sidebar-wrapper");
+ expect(wrapper).toHaveClass("custom-class");
+ });
+
+ test("function callback handling for setOpen", async () => {
+ const user = userEvent.setup();
+
+ const TestComponentWithCallback = () => {
+ const { setOpen } = useSidebar();
+ return (
+
+ );
+ };
+
+ render(
+
+
+
+
+ );
+
+ expect(screen.getByTestId("sidebar-open")).toHaveTextContent("true");
+ await user.click(screen.getByTestId("function-callback-button"));
+ expect(screen.getByTestId("sidebar-open")).toHaveTextContent("false");
+ });
+ });
+});
diff --git a/apps/web/modules/ui/components/sidebar/index.tsx b/apps/web/modules/ui/components/sidebar/index.tsx
new file mode 100644
index 0000000000..d2bee1f12c
--- /dev/null
+++ b/apps/web/modules/ui/components/sidebar/index.tsx
@@ -0,0 +1,691 @@
+"use client";
+
+import { Button } from "@/modules/ui/components/button";
+import { Input } from "@/modules/ui/components/input";
+import { Separator } from "@/modules/ui/components/separator";
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/modules/ui/components/sheet";
+import { Skeleton } from "@/modules/ui/components/skeleton";
+import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/modules/ui/components/tooltip";
+import { useIsMobile } from "@/modules/ui/hooks/use-mobile";
+import { cn } from "@/modules/ui/lib/utils";
+import { Slot } from "@radix-ui/react-slot";
+import { VariantProps, cva } from "class-variance-authority";
+import { Columns2Icon } from "lucide-react";
+import * as React from "react";
+
+const SIDEBAR_COOKIE_NAME = "sidebar_state";
+const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
+const SIDEBAR_WIDTH = "16rem";
+const SIDEBAR_WIDTH_MOBILE = "18rem";
+const SIDEBAR_WIDTH_ICON = "3rem";
+const SIDEBAR_KEYBOARD_SHORTCUT = "b";
+
+type SidebarContextProps = {
+ state: "expanded" | "collapsed";
+ open: boolean;
+ setOpen: (open: boolean) => void;
+ openMobile: boolean;
+ setOpenMobile: (open: boolean) => void;
+ isMobile: boolean;
+ toggleSidebar: () => void;
+};
+
+const SidebarContext = React.createContext(null);
+
+function useSidebar() {
+ const context = React.useContext(SidebarContext);
+ if (!context) {
+ throw new Error("useSidebar must be used within a SidebarProvider.");
+ }
+
+ return context;
+}
+
+const SidebarProvider = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ defaultOpen?: boolean;
+ open?: boolean;
+ onOpenChange?: (open: boolean) => void;
+ }
+>(
+ (
+ { defaultOpen = true, open: openProp, onOpenChange: setOpenProp, className, style, children, ...props },
+ ref
+ ) => {
+ const isMobile = useIsMobile();
+ const [openMobile, setOpenMobile] = React.useState(false);
+
+ // This is the internal state of the sidebar.
+ // We use openProp and setOpenProp for control from outside the component.
+ const [_open, _setOpen] = React.useState(defaultOpen);
+ const open = openProp ?? _open;
+ const setOpen = React.useCallback(
+ (value: boolean | ((value: boolean) => boolean)) => {
+ const openState = typeof value === "function" ? value(open) : value;
+ if (setOpenProp) {
+ setOpenProp(openState);
+ } else {
+ _setOpen(openState);
+ }
+
+ // This sets the cookie to keep the sidebar state.
+ document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`;
+ },
+ [setOpenProp, open]
+ );
+
+ // Helper to toggle the sidebar.
+ const toggleSidebar = React.useCallback(() => {
+ return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open);
+ }, [isMobile, setOpen, setOpenMobile]);
+
+ // Adds a keyboard shortcut to toggle the sidebar.
+ React.useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === SIDEBAR_KEYBOARD_SHORTCUT && (event.metaKey || event.ctrlKey)) {
+ event.preventDefault();
+ toggleSidebar();
+ }
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [toggleSidebar]);
+
+ // We add a state so that we can do data-state="expanded" or "collapsed".
+ // This makes it easier to style the sidebar with Tailwind classes.
+ const state = open ? "expanded" : "collapsed";
+
+ const contextValue = React.useMemo(
+ () => ({
+ state,
+ open,
+ setOpen,
+ isMobile,
+ openMobile,
+ setOpenMobile,
+ toggleSidebar,
+ }),
+ [state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
+ );
+
+ return (
+
+
+
+ {children}
+
+
+
+ );
+ }
+);
+SidebarProvider.displayName = "SidebarProvider";
+
+const Sidebar = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ side?: "left" | "right";
+ variant?: "sidebar" | "floating" | "inset";
+ collapsible?: "offcanvas" | "icon" | "none";
+ }
+>(({ side = "left", variant = "sidebar", collapsible = "offcanvas", className, children, ...props }, ref) => {
+ const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
+
+ if (collapsible === "none") {
+ return (
+
+ {children}
+
+ );
+ }
+
+ if (isMobile) {
+ return (
+
+
+
+ Sidebar
+ Displays the mobile sidebar.
+
+ {children}
+
+
+ );
+ }
+
+ return (
+
+ {/* This is what handles the sidebar gap on desktop */}
+
+
+
+ );
+});
+Sidebar.displayName = "Sidebar";
+
+const SidebarTrigger = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentProps
+>(({ className, onClick, ...props }, ref) => {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+});
+SidebarTrigger.displayName = "SidebarTrigger";
+
+const SidebarRail = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ const { toggleSidebar } = useSidebar();
+
+ return (
+
+ );
+ }
+);
+SidebarRail.displayName = "SidebarRail";
+
+const SidebarInset = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarInset.displayName = "SidebarInset";
+
+const SidebarInput = React.forwardRef, React.ComponentProps>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarInput.displayName = "SidebarInput";
+
+const SidebarHeader = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarHeader.displayName = "SidebarHeader";
+
+const SidebarFooter = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarFooter.displayName = "SidebarFooter";
+
+const SidebarSeparator = React.forwardRef<
+ React.ComponentRef,
+ React.ComponentProps
+>(({ className, ...props }, ref) => {
+ return (
+
+ );
+});
+SidebarSeparator.displayName = "SidebarSeparator";
+
+const SidebarContent = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarContent.displayName = "SidebarContent";
+
+const SidebarGroup = React.forwardRef>(
+ ({ className, ...props }, ref) => {
+ return (
+
+ );
+ }
+);
+SidebarGroup.displayName = "SidebarGroup";
+
+const SidebarGroupLabel = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "div";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ "group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarGroupLabel.displayName = "SidebarGroupLabel";
+
+const SidebarGroupAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & { asChild?: boolean }
+>(({ className, asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 after:md:hidden",
+ "group-data-[collapsible=icon]:hidden",
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarGroupAction.displayName = "SidebarGroupAction";
+
+const SidebarGroupContent = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+SidebarGroupContent.displayName = "SidebarGroupContent";
+
+const SidebarMenu = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+SidebarMenu.displayName = "SidebarMenu";
+
+const SidebarMenuItem = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+SidebarMenuItem.displayName = "SidebarMenuItem";
+
+const sidebarMenuButtonVariants = cva(
+ "peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-[[data-sidebar=menu-action]]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ {
+ variants: {
+ variant: {
+ default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
+ outline:
+ "bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
+ },
+ size: {
+ default: "h-8 text-sm",
+ sm: "h-7 text-xs",
+ lg: "h-12 text-sm group-data-[collapsible=icon]:!p-0",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ }
+);
+
+const SidebarMenuButton = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ isActive?: boolean;
+ tooltip?: string | React.ComponentProps;
+ } & VariantProps
+>(
+ (
+ {
+ asChild = false,
+ isActive = false,
+ variant = "default",
+ size = "default",
+ tooltip,
+ className,
+ ...props
+ },
+ ref
+ ) => {
+ const Comp = asChild ? Slot : "button";
+ const { isMobile, state } = useSidebar();
+
+ const button = (
+
+ );
+
+ if (!tooltip) {
+ return button;
+ }
+
+ if (typeof tooltip === "string") {
+ tooltip = {
+ children: tooltip,
+ };
+ }
+
+ return (
+
+ {button}
+
+
+ );
+ }
+);
+SidebarMenuButton.displayName = "SidebarMenuButton";
+
+const SidebarMenuAction = React.forwardRef<
+ HTMLButtonElement,
+ React.ComponentProps<"button"> & {
+ asChild?: boolean;
+ showOnHover?: boolean;
+ }
+>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+
+ return (
+ svg]:size-4 [&>svg]:shrink-0",
+ // Increases the hit area of the button on mobile.
+ "after:absolute after:-inset-2 after:md:hidden",
+ "peer-data-[size=sm]/menu-button:top-1",
+ "peer-data-[size=default]/menu-button:top-1.5",
+ "peer-data-[size=lg]/menu-button:top-2.5",
+ "group-data-[collapsible=icon]:hidden",
+ showOnHover &&
+ "peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarMenuAction.displayName = "SidebarMenuAction";
+
+const SidebarMenuBadge = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+SidebarMenuBadge.displayName = "SidebarMenuBadge";
+
+const SidebarMenuSkeleton = React.forwardRef<
+ HTMLDivElement,
+ React.ComponentProps<"div"> & {
+ showIcon?: boolean;
+ }
+>(({ className, showIcon = false, ...props }, ref) => {
+ // Random width between 50 to 90%.
+ const width = React.useMemo(() => {
+ return `${Math.floor(Math.random() * 40) + 50}%`;
+ }, []);
+
+ return (
+
+ {showIcon && }
+
+
+ );
+});
+SidebarMenuSkeleton.displayName = "SidebarMenuSkeleton";
+
+const SidebarMenuSub = React.forwardRef>(
+ ({ className, ...props }, ref) => (
+
+ )
+);
+SidebarMenuSub.displayName = "SidebarMenuSub";
+
+const SidebarMenuSubItem = React.forwardRef>(
+ ({ ...props }, ref) =>
+);
+SidebarMenuSubItem.displayName = "SidebarMenuSubItem";
+
+const SidebarMenuSubButton = React.forwardRef<
+ HTMLAnchorElement,
+ React.ComponentProps<"a"> & {
+ asChild?: boolean;
+ size?: "sm" | "md";
+ isActive?: boolean;
+ }
+>(({ asChild = false, size = "md", isActive, className, ...props }, ref) => {
+ const Comp = asChild ? Slot : "a";
+
+ return (
+ svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
+ "data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
+ size === "sm" && "text-xs",
+ size === "md" && "text-sm",
+ "group-data-[collapsible=icon]:hidden",
+ className
+ )}
+ {...props}
+ />
+ );
+});
+SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
+
+export {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupAction,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarInput,
+ SidebarInset,
+ SidebarMenu,
+ SidebarMenuAction,
+ SidebarMenuBadge,
+ SidebarMenuButton,
+ SidebarMenuItem,
+ SidebarMenuSkeleton,
+ SidebarMenuSub,
+ SidebarMenuSubButton,
+ SidebarMenuSubItem,
+ SidebarProvider,
+ SidebarRail,
+ SidebarSeparator,
+ SidebarTrigger,
+ useSidebar,
+};
diff --git a/apps/web/modules/ui/components/tab-bar/index.tsx b/apps/web/modules/ui/components/tab-bar/index.tsx
index 2dd4394078..d8209f8090 100644
--- a/apps/web/modules/ui/components/tab-bar/index.tsx
+++ b/apps/web/modules/ui/components/tab-bar/index.tsx
@@ -53,7 +53,7 @@ export const TabBar: React.FC = ({
)}
aria-label="Tabs">
{tabs.map((tab) => (
-
+