Info Box

PreviousNext

A simple info box component for displaying information, warnings, errors, loading states, and status messages.

Docs
aevrcomponent

Preview

Loading preview…
registry/lagos/ui/info-box.tsx
// ./registry/lagos/ui/info-box.tsx
"use client";

import {
  TickCircle,
  CloseCircle,
  Danger,
  InfoCircle,
  Warning2,
  Information,
} from "iconsax-react";
import Link from "next/link";
import React, { FC, ReactNode } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/lagos/ui/button";
import Loader from "@/registry/lagos/ui/loader";

// Types for InfoBox
export type InfoBoxType =
  | "warning"
  | "error"
  | "success"
  | "info"
  | "loading"
  | "default";

export type InfoBoxSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";

interface ActionObject {
  name: string;
  path?: string;
  onClick?: () => void;
  disabled?: boolean;
  type?: "default" | "primary" | "secondary" | "danger" | "ghost" | "tertiary";
  icon?: ReactNode;
  iconStart?: boolean;
  custom?: boolean;
}

// CVA variants for the main container
const infoBoxVariants = cva(
  "relative flex grow flex-wrap items-start border transition-colors duration-200",
  {
    variants: {
      type: {
        default: "",
        warning: "",
        error: "",
        success: "",
        info: "",
        loading: "",
      },
      size: {
        xs: "gap-2 rounded-xl p-2",
        sm: "gap-3 rounded-2xl p-3",
        md: "gap-4 rounded-3xl p-5 max-md:flex-col max-md:gap-2 max-md:p-3",
        lg: "gap-5 rounded-3xl p-6",
        xl: "gap-6 rounded-3xl p-8",
        "2xl": "gap-8 rounded-3xl p-10",
      },
      colorScheme: {
        default:
          "border-gray-200 bg-gray-50 text-gray-900 dark:border-gray-700 dark:bg-gray-950 dark:text-gray-100",
        full: "",
      },
    },
    compoundVariants: [
      // Full color scheme variants
      {
        type: "warning",
        colorScheme: "full",
        className:
          "bg-yellow-50 dark:bg-yellow-950 border-yellow-200 dark:border-yellow-800 text-yellow-900 dark:text-yellow-100",
      },
      {
        type: "error",
        colorScheme: "full",
        className:
          "bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-900 dark:text-red-100",
      },
      {
        type: "success",
        colorScheme: "full",
        className:
          "bg-green-50 dark:bg-green-950 border-green-200 dark:border-green-800 text-green-900 dark:text-green-100",
      },
      {
        type: "info",
        colorScheme: "full",
        className:
          "bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-900 dark:text-blue-100",
      },
      {
        type: "loading",
        colorScheme: "full",
        className:
          "bg-gray-50 dark:bg-gray-950 border-gray-200 dark:border-gray-800 text-gray-900 dark:text-gray-100",
      },
      {
        type: "default",
        colorScheme: "full",
        className:
          "bg-gray-50 dark:bg-gray-950 border-gray-200 dark:border-gray-800 text-gray-900 dark:text-gray-100",
      },
    ],
    defaultVariants: {
      type: "default",
      size: "md",
      colorScheme: "full",
    },
  }
);

// CVA variants for the icon container
const iconContainerVariants = cva("relative flex items-start justify-center", {
  variants: {
    type: {
      default: "bg-gray-100 dark:bg-gray-800",
      warning: "bg-yellow-100 dark:bg-yellow-900/20",
      error: "bg-red-100 dark:bg-red-900/20",
      success: "bg-green-100 dark:bg-green-900/20",
      info: "bg-blue-100 dark:bg-blue-900/20",
      loading: "bg-gray-100 dark:bg-gray-800",
    },
    size: {
      xs: "rounded-lg p-2",
      sm: "rounded-xl p-2",
      md: "rounded-2xl p-3",
      lg: "rounded-2xl p-4",
      xl: "rounded-3xl p-5",
      "2xl": "rounded-3xl p-6",
    },
  },
  defaultVariants: {
    type: "default",
    size: "md",
  },
});

// CVA variants for icons
const iconVariants = cva("icon", {
  variants: {
    type: {
      default: "text-gray-500",
      warning: "text-yellow-500",
      error: "text-red-500",
      success: "text-green-500",
      info: "text-blue-500",
      loading: "text-gray-500",
    },
    size: {
      xs: "w-3 h-3",
      sm: "w-4 h-4",
      md: "w-5 h-5",
      lg: "w-6 h-6",
      xl: "w-7 h-7",
      "2xl": "w-8 h-8",
    },
  },
  defaultVariants: {
    type: "default",
    size: "md",
  },
});

// CVA variants for title
const titleVariants = cva("font-medium", {
  variants: {
    size: {
      xs: "text-sm",
      sm: "text-base",
      md: "text-lg font-semibold max-md:text-base",
      lg: "text-xl font-semibold",
      xl: "text-2xl font-semibold",
      "2xl": "text-3xl font-bold",
    },
  },
  defaultVariants: {
    size: "md",
  },
});

// CVA variants for description
const descriptionVariants = cva("opacity-80", {
  variants: {
    size: {
      xs: "text-xs",
      sm: "text-sm",
      md: "text-base max-md:text-sm",
      lg: "text-lg",
      xl: "text-xl",
      "2xl": "text-2xl",
    },
  },
  defaultVariants: {
    size: "md",
  },
});

// CVA variants for actions container
const actionsVariants = cva("flex flex-wrap", {
  variants: {
    size: {
      xs: "mt-1 gap-1",
      sm: "mt-1.5 gap-1.5",
      md: "mt-2 gap-2",
      lg: "mt-3 gap-2.5",
      xl: "mt-4 gap-3",
      "2xl": "mt-5 gap-4",
    },
  },
  defaultVariants: {
    size: "md",
  },
});

// CVA variants for close button
const closeButtonVariants = cva("absolute", {
  variants: {
    size: {
      xs: "right-1 top-1",
      sm: "right-2 top-2",
      md: "right-3 top-3",
      lg: "right-4 top-4",
      xl: "right-5 top-5",
      "2xl": "right-6 top-6",
    },
  },
  defaultVariants: {
    size: "md",
  },
});

interface InfoBoxProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "title">,
    VariantProps<typeof infoBoxVariants> {
  loading?: boolean;
  icon?: ReactNode;
  title?: string | ReactNode;
  description?: string | ReactNode;
  actions?: Array<ActionObject | ReactNode>;
  children?: ReactNode;
  onClose?: () => void;
}

// Safe type guard function
const isActionObject = (action: unknown): action is ActionObject => {
  return (
    typeof action === "object" &&
    action !== null &&
    !React.isValidElement(action) &&
    "name" in (action as never)
  );
};

const InfoBox: FC<InfoBoxProps> = ({
  loading,
  icon,
  title,
  description,
  actions,
  children,
  type = "default",
  size = "md",
  colorScheme = "default",
  className,
  onClose,
  ...props
}) => {
  // Map type to appropriate icon
  const getIconByType = () => {
    const iconProps = {
      className: iconVariants({ type, size }),
      color: "currentColor" as const,
      variant: "Bulk" as const,
    };

    if (loading) {
      return <Loader loading className={iconVariants({ size })} />;
    }

    switch (type) {
      case "warning":
        return <Warning2 {...iconProps} />;
      case "error":
        return <Danger {...iconProps} />;
      case "success":
        return <TickCircle {...iconProps} />;
      case "info":
        return <InfoCircle {...iconProps} />;
      case "loading":
        return <Loader loading={true} className={iconVariants({ size })} />;
      default:
        return <Information {...iconProps} />;
    }
  };

  const getActionVariant = (actionType: string) => {
    switch (actionType) {
      case "default":
        return "primary";
      case "primary":
        return "primary";
      case "secondary":
        return "secondary";
      case "tertiary":
        return "tertiary";
      case "ghost":
        return "ghost";
      case "danger":
        return "danger";
      default:
        return "primary";
    }
  };

  const getActionSize = () => {
    switch (size) {
      case "xs":
      case "sm":
        return "sm";
      case "lg":
      case "xl":
      case "2xl":
        return "lg";
      default:
        return "md";
    }
  };

  const renderAction = (action: ActionObject, index: number) => {
    const variant = getActionVariant(action.type || "default");
    const buttonSize = getActionSize();

    const content = (
      <>
        {action.icon && action.iconStart && (
          <span className="icon">{action.icon}</span>
        )}
        <span>{action.name}</span>
        {action.icon && !action.iconStart && (
          <span className="icon">{action.icon}</span>
        )}
      </>
    );

    // If path exists, render Link with Button
    if (action.path) {
      return (
        <Button key={index} asChild variant={variant} size={buttonSize}>
          <Link href={action.path}>{content}</Link>
        </Button>
      );
    }

    // Otherwise render Button with onClick
    return (
      <Button
        key={index}
        variant={variant}
        size={buttonSize}
        onClick={action.onClick}
        disabled={action.disabled}
      >
        {content}
      </Button>
    );
  };

  const renderActionItem = (
    action: ActionObject | ReactNode,
    index: number
  ) => {
    if (isActionObject(action)) {
      return renderAction(action, index);
    } else {
      return <React.Fragment key={index}>{action}</React.Fragment>;
    }
  };

  const displayIcon = icon || getIconByType();

  return (
    <div
      className={cn(infoBoxVariants({ type, size, colorScheme }), className)}
      {...props}
    >
      {displayIcon && (
        <div className={iconContainerVariants({ type, size })}>
          {displayIcon}
        </div>
      )}

      <div className="flex-1">
        {title && <h3 className={titleVariants({ size })}>{title}</h3>}
        {description && (
          <div className={descriptionVariants({ size })}>{description}</div>
        )}

        {actions && actions.length > 0 && (
          <div className={actionsVariants({ size })}>
            {actions.map((action, index) => renderActionItem(action, index))}
          </div>
        )}

        {children}
      </div>

      {onClose && (
        <Button
          onClick={onClose}
          variant="ghost"
          size={getActionSize()}
          className={closeButtonVariants({ size })}
          aria-label="Close"
        >
          <CloseCircle className="icon" color="currentColor" variant="Bulk" />
        </Button>
      )}
    </div>
  );
};

export { InfoBox, infoBoxVariants };
export type { InfoBoxProps };

Installation

npx shadcn@latest add @aevr/info-box

Usage

import { InfoBox } from "@/components/info-box"
<InfoBox />