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; }