mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-04 19:21:51 +00:00
feat(overview): findings visualizations tabs component (#8999)
This commit is contained in:
+27
-1
@@ -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 <div>Hello</div>;
|
||||
}
|
||||
```
|
||||
|
||||
### 2. TypeScript Type Patterns (Required)
|
||||
|
||||
When defining union types for options, ALWAYS create a const object first, then extract the type:
|
||||
|
||||
|
||||
@@ -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 <GenericTabs tabs={tabs} defaultTabId="overview" />;
|
||||
}
|
||||
```
|
||||
|
||||
**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: <Eye size={16} />,
|
||||
content: ViewContent,
|
||||
contentProps: { data: myData },
|
||||
},
|
||||
{
|
||||
id: "config",
|
||||
label: "Config",
|
||||
icon: <Settings size={16} />,
|
||||
content: ConfigContent,
|
||||
},
|
||||
];
|
||||
|
||||
export function MyComponent() {
|
||||
return (
|
||||
<GenericTabs
|
||||
tabs={tabs}
|
||||
defaultTabId="view"
|
||||
onTabChange={(tabId) => 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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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"))
|
||||
* }
|
||||
* ];
|
||||
*
|
||||
* <GenericTabs tabs={tabs} defaultTabId="tab-1" onTabChange={(id) => console.log(id)} />
|
||||
*/
|
||||
export function GenericTabs({
|
||||
tabs,
|
||||
defaultTabId,
|
||||
className,
|
||||
listClassName,
|
||||
triggerClassName,
|
||||
contentClassName,
|
||||
onTabChange,
|
||||
}: GenericTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<string>(
|
||||
defaultTabId || tabs[0]?.id || "",
|
||||
);
|
||||
|
||||
const handleTabChange = (tabId: string) => {
|
||||
setActiveTab(tabId);
|
||||
onTabChange?.(tabId);
|
||||
};
|
||||
|
||||
if (!tabs || tabs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={handleTabChange}
|
||||
className={className}
|
||||
>
|
||||
<TabsList className={listClassName}>
|
||||
{tabs.map((tab) => (
|
||||
<TabsTrigger key={tab.id} value={tab.id} className={triggerClassName}>
|
||||
{tab.icon && <span className="mr-1">{tab.icon}</span>}
|
||||
{tab.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
{tabs.map((tab) => {
|
||||
const ContentComponent = tab.content;
|
||||
const isActive = activeTab === tab.id;
|
||||
|
||||
return (
|
||||
<TabsContent key={tab.id} value={tab.id} className={contentClassName}>
|
||||
{isActive && (
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ContentComponent
|
||||
isActive={isActive}
|
||||
{...(tab.contentProps || {})}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
</TabsContent>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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]";
|
||||
}
|
||||
@@ -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<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
className={cn("w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof TabsPrimitive.List>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
className={cn(buildListClassName(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(buildTriggerClassName(), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn(CONTENT_STYLES, className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Tabs, TabsContent, TabsList, TabsTrigger };
|
||||
Reference in New Issue
Block a user