From 6d9ef78df15d4e49d08550a59c8ee730e20d53d9 Mon Sep 17 00:00:00 2001
From: Alejandro Bailo <59607668+alejandrobailo@users.noreply.github.com>
Date: Wed, 25 Feb 2026 12:19:08 +0100
Subject: [PATCH] style(ui): improve shadcn primitives and add shared
components (#10153)
---
.../workflow/workflow-connect-llm.tsx | 62 +++++++++++++++--
ui/components/providers/index.ts | 1 +
ui/components/providers/radio-card.tsx | 66 +++++++++++++++++++
ui/components/shadcn/button/button.tsx | 3 +-
ui/components/shadcn/checkbox/checkbox.tsx | 4 +-
ui/components/shadcn/input/input.tsx | 2 +-
.../shadcn/search-input/search-input.tsx | 2 +-
ui/components/shadcn/tooltip.tsx | 2 +-
ui/components/shadcn/tree-view/index.ts | 1 +
ui/components/shadcn/tree-view/tree-leaf.tsx | 25 +++++--
ui/components/shadcn/tree-view/tree-node.tsx | 20 +++---
.../tree-view/tree-status-indicator.tsx | 41 ++++++++++++
ui/components/shadcn/tree-view/tree-view.tsx | 26 +++++++-
ui/components/shadcn/tree-view/utils.ts | 2 +-
ui/hooks/index.ts | 1 +
ui/hooks/use-scroll-hint.ts | 63 ++++++++++++++++++
ui/types/formSchemas.ts | 20 +++---
ui/types/tree.ts | 3 +-
18 files changed, 301 insertions(+), 43 deletions(-)
create mode 100644 ui/components/providers/radio-card.tsx
create mode 100644 ui/components/shadcn/tree-view/tree-status-indicator.tsx
create mode 100644 ui/hooks/use-scroll-hint.ts
diff --git a/ui/components/lighthouse/workflow/workflow-connect-llm.tsx b/ui/components/lighthouse/workflow/workflow-connect-llm.tsx
index d99ebd4811..455e244fca 100644
--- a/ui/components/lighthouse/workflow/workflow-connect-llm.tsx
+++ b/ui/components/lighthouse/workflow/workflow-connect-llm.tsx
@@ -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}`}
/>
-
+
);
diff --git a/ui/components/providers/index.ts b/ui/components/providers/index.ts
index f37085f087..1cd69cc9f3 100644
--- a/ui/components/providers/index.ts
+++ b/ui/components/providers/index.ts
@@ -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";
diff --git a/ui/components/providers/radio-card.tsx b/ui/components/providers/radio-card.tsx
new file mode 100644
index 0000000000..f4b5e13aca
--- /dev/null
+++ b/ui/components/providers/radio-card.tsx
@@ -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 (
+
+ );
+}
diff --git a/ui/components/shadcn/button/button.tsx b/ui/components/shadcn/button/button.tsx
index 89540afc1d..245ba61235 100644
--- a/ui/components/shadcn/button/button.tsx
+++ b/ui/components/shadcn/button/button.tsx
@@ -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",
diff --git a/ui/components/shadcn/checkbox/checkbox.tsx b/ui/components/shadcn/checkbox/checkbox.tsx
index 930b70a236..110cf77ed1 100644
--- a/ui/components/shadcn/checkbox/checkbox.tsx
+++ b/ui/components/shadcn/checkbox/checkbox.tsx
@@ -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
diff --git a/ui/components/shadcn/input/input.tsx b/ui/components/shadcn/input/input.tsx
index 298a4b21d1..cb82494817 100644
--- a/ui/components/shadcn/input/input.tsx
+++ b/ui/components/shadcn/input/input.tsx
@@ -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",
},
diff --git a/ui/components/shadcn/search-input/search-input.tsx b/ui/components/shadcn/search-input/search-input.tsx
index 16397460b8..18ee5428f2 100644
--- a/ui/components/shadcn/search-input/search-input.tsx
+++ b/ui/components/shadcn/search-input/search-input.tsx
@@ -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",
},
diff --git a/ui/components/shadcn/tooltip.tsx b/ui/components/shadcn/tooltip.tsx
index b09f7c355a..c024b79c88 100644
--- a/ui/components/shadcn/tooltip.tsx
+++ b/ui/components/shadcn/tooltip.tsx
@@ -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}
diff --git a/ui/components/shadcn/tree-view/index.ts b/ui/components/shadcn/tree-view/index.ts
index 6483d3d611..c4cb14a1a3 100644
--- a/ui/components/shadcn/tree-view/index.ts
+++ b/ui/components/shadcn/tree-view/index.ts
@@ -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,
diff --git a/ui/components/shadcn/tree-view/tree-leaf.tsx b/ui/components/shadcn/tree-view/tree-leaf.tsx
index a9cb6c815b..1348bd9324 100644
--- a/ui/components/shadcn/tree-view/tree-leaf.tsx
+++ b/ui/components/shadcn/tree-view/tree-leaf.tsx
@@ -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 ? (
+
+ ) : 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 && }
- {!item.isLoading && item.status && (
-
+ {!showCheckboxes && item.isLoading && }
+ {!showCheckboxes && statusIcon}
+
+ {showCheckboxes && shouldReplaceCheckboxWithState && (
+ <>
+ {item.isLoading && }
+ {statusIcon}
+ >
)}
- {showCheckboxes && (
+ {showCheckboxes && !shouldReplaceCheckboxWithState && (
+ ) : 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({
)}
- {!item.isLoading && item.status && (
-
- )}
+ {statusIcon}
{showCheckboxes && (
{item.children?.map((child) => (
- {child.children ? (
+ {child.children && child.children.length > 0 ? (
) : (
diff --git a/ui/components/shadcn/tree-view/tree-status-indicator.tsx b/ui/components/shadcn/tree-view/tree-status-indicator.tsx
new file mode 100644
index 0000000000..4a7adb06e4
--- /dev/null
+++ b/ui/components/shadcn/tree-view/tree-status-indicator.tsx
@@ -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 (
+
+
+
+
+
+
+
+ {errorMessage}
+
+
+ );
+ }
+
+ return ;
+}
diff --git a/ui/components/shadcn/tree-view/tree-view.tsx b/ui/components/shadcn/tree-view/tree-view.tsx
index 940574677d..e749f033c2 100644
--- a/ui/components/shadcn/tree-view/tree-view.tsx
+++ b/ui/components/shadcn/tree-view/tree-view.tsx
@@ -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([]);
- const [internalExpandedIds, setInternalExpandedIds] = useState([]);
+ const [internalExpandedIds, setInternalExpandedIds] = useState(
+ expandAll ? getInitialExpandedIds(data) : [],
+ );
const selectedIds = controlledSelectedIds ?? internalSelectedIds;
const expandedIds = controlledExpandedIds ?? internalExpandedIds;
@@ -108,7 +129,7 @@ export function TreeView({
{items.map((item) => (
-
- {item.children ? (
+ {item.children && item.children.length > 0 ? (
) : (
diff --git a/ui/components/shadcn/tree-view/utils.ts b/ui/components/shadcn/tree-view/utils.ts
index 1df696518d..f1969fd72d 100644
--- a/ui/components/shadcn/tree-view/utils.ts
+++ b/ui/components/shadcn/tree-view/utils.ts
@@ -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.
diff --git a/ui/hooks/index.ts b/ui/hooks/index.ts
index f03f9b4ba0..75ff6da61d 100644
--- a/ui/hooks/index.ts
+++ b/ui/hooks/index.ts
@@ -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";
diff --git a/ui/hooks/use-scroll-hint.ts b/ui/hooks/use-scroll-hint.ts
new file mode 100644
index 0000000000..4d18c10d30
--- /dev/null
+++ b/ui/hooks/use-scroll-hint.ts
@@ -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(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) => {
+ setShowScrollHint(shouldShowScrollHint(event.currentTarget));
+ };
+
+ return {
+ containerRef,
+ showScrollHint,
+ handleScroll,
+ };
+}
diff --git a/ui/types/formSchemas.ts b/ui/types/formSchemas.ts
index 36503a0492..f87c7661f2 100644
--- a/ui/types/formSchemas.ts
+++ b/ui/types/formSchemas.ts
@@ -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"),
diff --git a/ui/types/tree.ts b/ui/types/tree.ts
index ff18bf77c5..d306f01238 100644
--- a/ui/types/tree.ts
+++ b/ui/types/tree.ts
@@ -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;
}