diff --git a/ui/AGENTS.md b/ui/AGENTS.md index a7316a429f..ccee724d8b 100644 --- a/ui/AGENTS.md +++ b/ui/AGENTS.md @@ -13,7 +13,33 @@ ## Critical Architecture Rules (Non-Negotiable) -### 1. TypeScript Type Patterns (Required) +### 1. React Imports (Required) + +**NEVER** import React with `import * as React`. Instead, import only what you need: + +**❌ DON'T:** + +```typescript +import * as React from "react"; +import React, { useState } from "react"; +``` + +**✅ DO:** + +```typescript +import { useState, useEffect } from "react"; +``` + +**When you need nothing from React (e.g., only using JSX):** + +```typescript +// No React import needed - JSX works without it in modern Next.js +export function MyComponent() { + return
Hello
; +} +``` + +### 2. TypeScript Type Patterns (Required) When defining union types for options, ALWAYS create a const object first, then extract the type: diff --git a/ui/components/shadcn/README.md b/ui/components/shadcn/README.md index af3bc0348d..127832a4f7 100644 --- a/ui/components/shadcn/README.md +++ b/ui/components/shadcn/README.md @@ -16,6 +16,9 @@ shadcn/ │ └── resource-stats-card/ │ ├── resource-stats-card.tsx │ ├── resource-stats-card.example.tsx +├── tabs/ +│ ├── tabs.tsx # Base tab components +│ ├── generic-tabs.tsx # Generic reusable tabs with lazy loading ├── index.ts # Barrel exports └── README.md ``` @@ -27,8 +30,83 @@ All shadcn components can be imported from `@/components/shadcn`: ```tsx import { Card, CardHeader, CardContent } from "@/components/shadcn"; import { ResourceStatsCard } from "@/components/shadcn"; +import { GenericTabs, type TabItem } from "@/components/shadcn"; ``` +### GenericTabs Component + +The `GenericTabs` component provides a flexible, lazy-loaded tabs interface. Content is only rendered when the tab is active, improving performance. + +**Basic Example:** + +```tsx +import { lazy } from "react"; +import { GenericTabs, type TabItem } from "@/components/shadcn"; + +const OverviewContent = lazy(() => import("./OverviewContent")); +const DetailsContent = lazy(() => import("./DetailsContent")); + +const tabs: TabItem[] = [ + { + id: "overview", + label: "Overview", + content: OverviewContent, + }, + { + id: "details", + label: "Details", + content: DetailsContent, + }, +]; + +export function MyComponent() { + return ; +} +``` + +**With Icons and Props:** + +```tsx +import { Eye, Settings } from "lucide-react"; +import { GenericTabs, type TabItem } from "@/components/shadcn"; + +const tabs: TabItem[] = [ + { + id: "view", + label: "View", + icon: , + content: ViewContent, + contentProps: { data: myData }, + }, + { + id: "config", + label: "Config", + icon: , + content: ConfigContent, + }, +]; + +export function MyComponent() { + return ( + console.log("Active tab:", tabId)} + /> + ); +} +``` + +**Props:** + +- `tabs` - Array of `TabItem` objects +- `defaultTabId` - Initial active tab ID (defaults to first tab) +- `className` - Wrapper class +- `listClassName` - TabsList class +- `triggerClassName` - TabsTrigger class +- `contentClassName` - TabsContent class +- `onTabChange` - Callback fired when tab changes + ## Adding New shadcn Components When adding new shadcn components using the CLI: diff --git a/ui/components/shadcn/index.ts b/ui/components/shadcn/index.ts index d96af75fe4..94ed272661 100644 --- a/ui/components/shadcn/index.ts +++ b/ui/components/shadcn/index.ts @@ -6,3 +6,5 @@ export * from "./card/resource-stats-card/resource-stats-card-content"; export * from "./card/resource-stats-card/resource-stats-card-divider"; export * from "./card/resource-stats-card/resource-stats-card-header"; export * from "./select/select"; +export * from "./tabs/generic-tabs"; +export * from "./tabs/tabs"; diff --git a/ui/components/shadcn/tabs/generic-tabs.tsx b/ui/components/shadcn/tabs/generic-tabs.tsx new file mode 100644 index 0000000000..f87b4e3513 --- /dev/null +++ b/ui/components/shadcn/tabs/generic-tabs.tsx @@ -0,0 +1,102 @@ +"use client"; + +import type { ComponentType, ReactNode } from "react"; +import { Suspense, useState } from "react"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "./tabs"; + +export interface TabItem { + id: string; + label: string; + icon?: ReactNode; + content: ComponentType<{ isActive: boolean }>; + contentProps?: Record; +} + +interface GenericTabsProps { + tabs: TabItem[]; + defaultTabId?: string; + className?: string; + listClassName?: string; + triggerClassName?: string; + contentClassName?: string; + onTabChange?: (tabId: string) => void; +} + +/** + * A generic tabs component that accepts an array of tab objects with lazy-loaded content. + * + * @example + * const tabs: TabItem[] = [ + * { + * id: "tab-1", + * label: "Tab 1", + * content: lazy(() => import("./Tab1Content")), + * contentProps: { key: "value" } + * }, + * { + * id: "tab-2", + * label: "Tab 2", + * content: lazy(() => import("./Tab2Content")) + * } + * ]; + * + * console.log(id)} /> + */ +export function GenericTabs({ + tabs, + defaultTabId, + className, + listClassName, + triggerClassName, + contentClassName, + onTabChange, +}: GenericTabsProps) { + const [activeTab, setActiveTab] = useState( + defaultTabId || tabs[0]?.id || "", + ); + + const handleTabChange = (tabId: string) => { + setActiveTab(tabId); + onTabChange?.(tabId); + }; + + if (!tabs || tabs.length === 0) { + return null; + } + + return ( + + + {tabs.map((tab) => ( + + {tab.icon && {tab.icon}} + {tab.label} + + ))} + + + {tabs.map((tab) => { + const ContentComponent = tab.content; + const isActive = activeTab === tab.id; + + return ( + + {isActive && ( + Loading...}> + + + )} + + ); + })} + + ); +} diff --git a/ui/components/shadcn/tabs/tabs.constants.ts b/ui/components/shadcn/tabs/tabs.constants.ts new file mode 100644 index 0000000000..62f7e9c144 --- /dev/null +++ b/ui/components/shadcn/tabs/tabs.constants.ts @@ -0,0 +1,43 @@ +/** + * Trigger component style parts using semantic class names + */ +const TRIGGER_STYLES = { + base: "relative inline-flex items-center justify-center gap-2 px-4 py-3 text-sm font-medium transition-colors disabled:pointer-events-none disabled:opacity-50", + border: "border-r border-[#E9E9F0] last:border-r-0 dark:border-[#171D30]", + text: "text-slate-600 hover:text-slate-900 dark:text-slate-400 dark:hover:text-white", + active: + "data-[state=active]:text-slate-900 dark:data-[state=active]:text-white", + underline: + "after:absolute after:bottom-0 after:left-1/2 after:h-0.5 after:w-0 after:-translate-x-1/2 after:bg-[#20B853] after:transition-all data-[state=active]:after:w-[calc(100%-theme(spacing.5))]", + focus: + "focus-visible:ring-2 focus-visible:ring-[#20B853] focus-visible:ring-offset-2 focus-visible:ring-offset-white focus-visible:outline-none dark:focus-visible:ring-offset-slate-950", + icon: "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", +} as const; + +/** + * Content component styles + */ +export const CONTENT_STYLES = + "mt-2 focus-visible:rounded-md focus-visible:outline-1 focus-visible:ring-[3px] focus-visible:border-ring focus-visible:outline-ring focus-visible:ring-ring/50" as const; + +/** + * Build trigger className by combining style parts + */ +export function buildTriggerClassName(): string { + return [ + TRIGGER_STYLES.base, + TRIGGER_STYLES.border, + TRIGGER_STYLES.text, + TRIGGER_STYLES.active, + TRIGGER_STYLES.underline, + TRIGGER_STYLES.focus, + TRIGGER_STYLES.icon, + ].join(" "); +} + +/** + * Build list className + */ +export function buildListClassName(): string { + return "inline-flex w-full items-center border-[#E9E9F0] dark:border-[#171D30]"; +} diff --git a/ui/components/shadcn/tabs/tabs.tsx b/ui/components/shadcn/tabs/tabs.tsx new file mode 100644 index 0000000000..8208a7d292 --- /dev/null +++ b/ui/components/shadcn/tabs/tabs.tsx @@ -0,0 +1,66 @@ +"use client"; + +import * as TabsPrimitive from "@radix-ui/react-tabs"; +import type { ComponentProps } from "react"; + +import { cn } from "@/lib/utils"; + +import { + buildListClassName, + buildTriggerClassName, + CONTENT_STYLES, +} from "./tabs.constants"; + +function Tabs({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +function TabsList({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +function TabsTrigger({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +function TabsContent({ + className, + ...props +}: ComponentProps) { + return ( + + ); +} + +export { Tabs, TabsContent, TabsList, TabsTrigger };