style(ui): improve shadcn primitives and add shared components (#10153)

This commit is contained in:
Alejandro Bailo
2026-02-25 12:19:08 +01:00
committed by GitHub
parent 9ee8072572
commit 6d9ef78df1
18 changed files with 301 additions and 43 deletions

View File

@@ -5,7 +5,7 @@ import { Spacer } from "@heroui/spacer";
import { usePathname, useSearchParams } from "next/navigation";
import React from "react";
import { VerticalSteps } from "@/components/providers/workflow/vertical-steps";
import { cn } from "@/lib/utils";
import type { LighthouseProvider } from "@/types/lighthouse";
import { getProviderConfig } from "../llm-provider-registry";
@@ -75,12 +75,60 @@ export const WorkflowConnectLLM = () => {
value={currentStep + 1}
valueLabel={`${currentStep + 1} of ${steps.length}`}
/>
<VerticalSteps
hideProgressBars
currentStep={currentStep}
stepClassName="border border-border-neutral-primary aria-[current]:bg-bg-neutral-primary cursor-default"
steps={steps}
/>
<nav aria-label="Progress">
<ol className="flex flex-col gap-y-3">
{steps.map((step, index) => {
const isActive = index === currentStep;
const isComplete = index < currentStep;
return (
<li
key={step.title}
className="border-border-neutral-primary rounded-large border px-3 py-2.5"
>
<div className="flex items-center gap-4">
<div
className={cn(
"flex h-[34px] w-[34px] items-center justify-center rounded-full border text-sm font-semibold",
isComplete &&
"bg-button-primary border-button-primary text-white",
isActive &&
"border-button-primary text-button-primary bg-transparent",
!isActive &&
!isComplete &&
"text-default-500 border-border-neutral-primary bg-transparent",
)}
>
{index + 1}
</div>
<div className="flex-1 text-left">
<div
className={cn(
"text-medium font-medium",
isActive || isComplete
? "text-default-foreground"
: "text-default-500",
)}
>
{step.title}
</div>
<div
className={cn(
"text-small",
isActive || isComplete
? "text-default-600"
: "text-default-500",
)}
>
{step.description}
</div>
</div>
</div>
</li>
);
})}
</ol>
</nav>
<Spacer y={4} />
</section>
);

View File

@@ -5,4 +5,5 @@ export * from "./forms/delete-form";
export * from "./link-to-scans";
export * from "./muted-findings-config-button";
export * from "./provider-info";
export * from "./radio-card";
export * from "./radio-group-provider";

View File

@@ -0,0 +1,66 @@
import { cn } from "@/lib/utils";
interface RadioCardProps {
icon: React.ComponentType<{ className?: string }>;
title: string;
onClick: () => void;
selected?: boolean;
disabled?: boolean;
/** Optional trailing content (e.g. a CTA badge). */
children?: React.ReactNode;
}
export function RadioCard({
icon: Icon,
title,
onClick,
selected = false,
disabled = false,
children,
}: RadioCardProps) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onClick}
disabled={disabled}
className={cn(
"flex min-h-[72px] w-full items-center gap-4 rounded-lg border px-3 py-2.5 text-left transition-colors",
disabled
? "border-border-neutral-primary bg-bg-neutral-tertiary cursor-not-allowed"
: selected
? "border-primary bg-bg-neutral-tertiary cursor-pointer"
: "hover:border-primary border-border-neutral-primary bg-bg-neutral-tertiary cursor-pointer",
)}
>
<div
className={cn(
"size-[18px] shrink-0 rounded-full border shadow-xs",
selected
? "border-primary bg-primary"
: "border-border-neutral-primary bg-bg-input-primary",
)}
/>
<div className="flex min-w-0 flex-1 items-center gap-1.5">
<Icon
className={cn(
"size-[18px] shrink-0",
disabled ? "text-text-neutral-tertiary" : "text-muted-foreground",
)}
/>
<span
className={cn(
"truncate text-sm leading-6",
disabled ? "text-text-neutral-tertiary" : "text-foreground",
)}
>
{title}
</span>
</div>
{children}
</button>
);
}

View File

@@ -21,7 +21,7 @@ const buttonVariants = cva(
"border border-border-neutral-secondary bg-bg-neutral-secondary hover:bg-bg-neutral-tertiary active:bg-border-neutral-tertiary text-text-neutral-primary focus-visible:ring-border-neutral-tertiary/50",
ghost:
"text-text-neutral-primary hover:bg-bg-neutral-tertiary active:bg-border-neutral-secondary focus-visible:ring-border-neutral-secondary/50",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover",
link: "text-button-tertiary underline-offset-4 hover:text-button-tertiary-hover disabled:bg-transparent",
// Menu variant like secondary but more padding and the back is almost transparent
menu: "backdrop-blur-xl bg-white/60 dark:bg-white/5 border border-white/80 dark:border-white/10 text-text-neutral-primary dark:text-white shadow-lg hover:bg-white/70 dark:hover:bg-white/10 hover:border-white/90 dark:hover:border-white/30 active:bg-white/80 dark:active:bg-white/15 active:scale-[0.98] focus-visible:ring-button-primary/50 transition-all duration-200",
"menu-active":
@@ -33,6 +33,7 @@ const buttonVariants = cva(
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
xl: "h-12 rounded-md px-8 text-base has-[>svg]:px-6",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",

View File

@@ -46,9 +46,9 @@ function Checkbox({
// Default state
"bg-bg-input-primary border-border-input-primary shadow-[0_1px_2px_0_rgba(0,0,0,0.1)]",
// Checked state
"data-[state=checked]:bg-button-primary data-[state=checked]:border-button-primary data-[state=checked]:text-white",
"data-[state=checked]:bg-button-tertiary-active data-[state=checked]:border-button-tertiary-active data-[state=checked]:text-white",
// Indeterminate state
"data-[state=indeterminate]:bg-button-primary data-[state=indeterminate]:border-button-primary data-[state=indeterminate]:text-white",
"data-[state=indeterminate]:bg-button-tertiary-active data-[state=indeterminate]:border-button-tertiary-active data-[state=indeterminate]:text-white",
// Focus state
"focus-visible:border-border-input-primary-press focus-visible:ring-border-input-primary-press/50 focus-visible:ring-2",
// Disabled state

View File

@@ -11,7 +11,7 @@ const inputVariants = cva(
variants: {
variant: {
default:
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-border-input-primary-press focus:ring-offset-1 placeholder:text-text-neutral-tertiary",
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press placeholder:text-text-neutral-tertiary",
ghost:
"border-transparent bg-transparent hover:bg-bg-neutral-tertiary focus:bg-bg-neutral-tertiary placeholder:text-text-neutral-tertiary",
},

View File

@@ -25,7 +25,7 @@ const searchInputVariants = cva(
variants: {
variant: {
default:
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-border-input-primary-press focus:ring-offset-1",
"border-border-input-primary bg-bg-input-primary dark:bg-input/30 hover:bg-bg-neutral-secondary dark:hover:bg-input/50 focus:border-border-input-primary-press focus:ring-1 focus:ring-inset focus:ring-border-input-primary-press",
ghost:
"border-transparent bg-transparent hover:bg-bg-neutral-tertiary focus:bg-bg-neutral-tertiary",
},

View File

@@ -45,7 +45,7 @@ function TooltipContent({
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-lg border px-3 py-1.5 text-xs text-balance shadow-lg",
"border-border-neutral-tertiary bg-bg-neutral-tertiary text-text-neutral-primary animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit max-w-[min(700px,calc(100vw-2rem))] origin-(--radix-tooltip-content-transform-origin) rounded-lg border px-2 py-1.5 text-left text-xs break-all whitespace-normal shadow-lg",
className,
)}
{...props}

View File

@@ -3,6 +3,7 @@ export { TreeLeaf } from "./tree-leaf";
export { TreeNode } from "./tree-node";
export { TreeSpinner } from "./tree-spinner";
export { TreeStatusIcon } from "./tree-status-icon";
export { TreeStatusIndicator } from "./tree-status-indicator";
export { TreeView } from "./tree-view";
export {
getAllDescendantIds,

View File

@@ -8,7 +8,7 @@ import { TreeLeafProps } from "@/types/tree";
import { TreeItemLabel } from "./tree-item-label";
import { TreeSpinner } from "./tree-spinner";
import { TreeStatusIcon } from "./tree-status-icon";
import { TreeStatusIndicator } from "./tree-status-indicator";
import { getTreeLeafPadding } from "./utils";
/**
@@ -30,6 +30,15 @@ export function TreeLeaf({
renderItem,
}: TreeLeafProps) {
const isSelected = selectedIds.includes(item.id);
const shouldReplaceCheckboxWithState =
showCheckboxes && (item.isLoading || Boolean(item.status));
const statusIcon =
!item.isLoading && item.status ? (
<TreeStatusIndicator
status={item.status}
errorMessage={item.errorMessage}
/>
) : null;
const handleSelect = () => {
if (!item.disabled) {
@@ -50,7 +59,6 @@ export function TreeLeaf({
"flex items-center gap-2 rounded-md px-2 py-1.5",
"hover:bg-prowler-white/5 cursor-pointer",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
isSelected && "bg-prowler-white/10",
item.disabled && "cursor-not-allowed opacity-50",
item.className,
)}
@@ -62,12 +70,17 @@ export function TreeLeaf({
aria-selected={isSelected}
aria-disabled={item.disabled}
>
{item.isLoading && <TreeSpinner />}
{!item.isLoading && item.status && (
<TreeStatusIcon status={item.status} />
{!showCheckboxes && item.isLoading && <TreeSpinner />}
{!showCheckboxes && statusIcon}
{showCheckboxes && shouldReplaceCheckboxWithState && (
<>
{item.isLoading && <TreeSpinner />}
{statusIcon}
</>
)}
{showCheckboxes && (
{showCheckboxes && !shouldReplaceCheckboxWithState && (
<Checkbox
size="sm"
checked={isSelected}

View File

@@ -11,7 +11,7 @@ import { TreeNodeProps } from "@/types/tree";
import { TreeItemLabel } from "./tree-item-label";
import { TreeLeaf } from "./tree-leaf";
import { TreeSpinner } from "./tree-spinner";
import { TreeStatusIcon } from "./tree-status-icon";
import { TreeStatusIndicator } from "./tree-status-indicator";
import { getAllDescendantIds, getTreeNodePadding } from "./utils";
/**
@@ -34,11 +34,17 @@ export function TreeNode({
onExpandedChange,
showCheckboxes,
renderItem,
expandAll,
enableSelectChildren,
}: TreeNodeProps) {
const isExpanded = expandAll || expandedIds.includes(item.id);
const isExpanded = expandedIds.includes(item.id);
const isSelected = selectedIds.includes(item.id);
const statusIcon =
!item.isLoading && item.status ? (
<TreeStatusIndicator
status={item.status}
errorMessage={item.errorMessage}
/>
) : null;
// Calculate indeterminate state based on descendant selection
const descendantIds = getAllDescendantIds(item);
@@ -91,7 +97,6 @@ export function TreeNode({
"flex items-center gap-2 rounded-md px-2 py-1.5",
"hover:bg-prowler-white/5 cursor-pointer",
"focus-visible:ring-border-input-primary-press focus-visible:ring-2 focus-visible:outline-none",
isSelected && "bg-prowler-white/10",
item.disabled && "cursor-not-allowed opacity-50",
item.className,
)}
@@ -125,9 +130,7 @@ export function TreeNode({
)}
</button>
{!item.isLoading && item.status && (
<TreeStatusIcon status={item.status} />
)}
{statusIcon}
{showCheckboxes && (
<Checkbox
@@ -170,7 +173,7 @@ export function TreeNode({
>
{item.children?.map((child) => (
<li key={child.id}>
{child.children ? (
{child.children && child.children.length > 0 ? (
<TreeNode
item={child}
level={level + 1}
@@ -180,7 +183,6 @@ export function TreeNode({
onExpandedChange={onExpandedChange}
showCheckboxes={showCheckboxes}
renderItem={renderItem}
expandAll={expandAll}
enableSelectChildren={enableSelectChildren}
/>
) : (

View File

@@ -0,0 +1,41 @@
"use client";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn/tooltip";
import { TreeItemStatus } from "@/types/tree";
import { TreeStatusIcon } from "./tree-status-icon";
interface TreeStatusIndicatorProps {
status?: TreeItemStatus;
errorMessage?: string;
}
export function TreeStatusIndicator({
status,
errorMessage,
}: TreeStatusIndicatorProps) {
if (!status) {
return null;
}
if (status === "error" && errorMessage) {
return (
<Tooltip>
<TooltipTrigger asChild>
<span>
<TreeStatusIcon status={status} />
</span>
</TooltipTrigger>
<TooltipContent>
<p className="text-xs">{errorMessage}</p>
</TooltipContent>
</Tooltip>
);
}
return <TreeStatusIcon status={status} />;
}

View File

@@ -9,6 +9,25 @@ import { TreeLeaf } from "./tree-leaf";
import { TreeNode } from "./tree-node";
import { getAllDescendantIds } from "./utils";
function getInitialExpandedIds(data: TreeDataItem[] | TreeDataItem): string[] {
const items = Array.isArray(data) ? data : [data];
const expandableIds: string[] = [];
const stack = [...items];
while (stack.length > 0) {
const current = stack.pop();
if (!current) continue;
if (current.children && current.children.length > 0) {
expandableIds.push(current.id);
stack.push(...current.children);
}
}
return expandableIds;
}
/**
* TreeView component for rendering hierarchical data structures.
*
@@ -56,7 +75,9 @@ export function TreeView({
renderItem,
}: TreeViewProps) {
const [internalSelectedIds, setInternalSelectedIds] = useState<string[]>([]);
const [internalExpandedIds, setInternalExpandedIds] = useState<string[]>([]);
const [internalExpandedIds, setInternalExpandedIds] = useState<string[]>(
expandAll ? getInitialExpandedIds(data) : [],
);
const selectedIds = controlledSelectedIds ?? internalSelectedIds;
const expandedIds = controlledExpandedIds ?? internalExpandedIds;
@@ -108,7 +129,7 @@ export function TreeView({
<ul className="space-y-1">
{items.map((item) => (
<li key={item.id}>
{item.children ? (
{item.children && item.children.length > 0 ? (
<TreeNode
item={item}
level={0}
@@ -118,7 +139,6 @@ export function TreeView({
onExpandedChange={handleExpandedChange}
showCheckboxes={showCheckboxes}
renderItem={renderItem}
expandAll={expandAll}
enableSelectChildren={enableSelectChildren}
/>
) : (

View File

@@ -5,7 +5,7 @@ import { TreeDataItem } from "@/types/tree";
* Used to calculate consistent padding for nested tree items.
*/
export const TREE_INDENT_REM = 1.25;
export const TREE_LEAF_EXTRA_PADDING_REM = 1.5;
export const TREE_LEAF_EXTRA_PADDING_REM = 1.75;
/**
* Calculates the left padding for a tree node based on its nesting level.

View File

@@ -3,6 +3,7 @@ export * from "./use-credentials-form";
export * from "./use-form-server-errors";
export * from "./use-local-storage";
export * from "./use-related-filters";
export * from "./use-scroll-hint";
export * from "./use-sidebar";
export * from "./use-store";
export * from "./use-url-filters";

View File

@@ -0,0 +1,63 @@
"use client";
import { UIEvent, useEffect, useRef, useState } from "react";
interface UseScrollHintOptions {
enabled?: boolean;
refreshToken?: string | number;
}
const SCROLL_THRESHOLD_PX = 4;
function shouldShowScrollHint(element: HTMLDivElement) {
const hasOverflow =
element.scrollHeight - element.clientHeight > SCROLL_THRESHOLD_PX;
const isAtBottom =
element.scrollTop + element.clientHeight >=
element.scrollHeight - SCROLL_THRESHOLD_PX;
return hasOverflow && !isAtBottom;
}
export function useScrollHint({
enabled = true,
refreshToken,
}: UseScrollHintOptions = {}) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [showScrollHint, setShowScrollHint] = useState(false);
useEffect(() => {
if (!enabled) {
setShowScrollHint(false);
return;
}
const element = containerRef.current;
if (!element) return;
const recalculate = () => {
const el = containerRef.current;
if (!el) return;
setShowScrollHint(shouldShowScrollHint(el));
};
const observer = new ResizeObserver(recalculate);
observer.observe(element);
recalculate();
return () => {
observer.disconnect();
};
}, [enabled, refreshToken]);
const handleScroll = (event: UIEvent<HTMLDivElement>) => {
setShowScrollHint(shouldShowScrollHint(event.currentTarget));
};
return {
containerRef,
showScrollHint,
handleScroll,
};
}

View File

@@ -80,55 +80,55 @@ export const addProviderFormSchema = z
z.object({
providerType: z.literal("aws"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(12, "Provider ID is required"),
}),
z.object({
providerType: z.literal("azure"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
awsCredentialsType: z.string().optional(),
}),
z.object({
providerType: z.literal("m365"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("gcp"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
awsCredentialsType: z.string().optional(),
}),
z.object({
providerType: z.literal("kubernetes"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
awsCredentialsType: z.string().optional(),
}),
z.object({
providerType: z.literal("github"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("iac"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("oraclecloud"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("mongodbatlas"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("alibabacloud"),
[ProviderCredentialFields.PROVIDER_ALIAS]: z.string(),
providerUid: z.string(),
providerUid: z.string().trim().min(1, "Provider ID is required"),
}),
z.object({
providerType: z.literal("cloudflare"),

View File

@@ -35,6 +35,8 @@ export interface TreeDataItem {
isLoading?: boolean;
/** Status indicator shown after loading (success/error) */
status?: TreeItemStatus;
/** Optional error detail used by status icon tooltip */
errorMessage?: string;
/** Additional CSS classes for the item */
className?: string;
}
@@ -97,7 +99,6 @@ export interface TreeNodeProps {
onExpandedChange: (ids: string[]) => void;
showCheckboxes: boolean;
renderItem?: (params: TreeRenderItemParams) => React.ReactNode;
expandAll: boolean;
enableSelectChildren: boolean;
}