diff --git a/packages/ui/package.json b/packages/ui/package.json index 094f7e75f..17717e9d5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -72,6 +72,7 @@ "./utils/formatting": "./src/utils/formatting.ts", "./lib/primitive": "./src/lib/primitive.ts", "./hooks/use-media-query": "./src/hooks/use-media-query.ts", + "./AlertBanner": "./src/components/AlertBanner.tsx", "./ui/table": "./src/components/ui/table.tsx" }, "module": "./src/index.ts", diff --git a/packages/ui/src/components/AlertBanner.tsx b/packages/ui/src/components/AlertBanner.tsx new file mode 100644 index 000000000..b781e8611 --- /dev/null +++ b/packages/ui/src/components/AlertBanner.tsx @@ -0,0 +1,140 @@ +import { ReactNode } from 'react'; +import { LuCircleAlert, LuCircleCheck, LuInfo } from 'react-icons/lu'; +import { tv } from 'tailwind-variants'; + +import { cn } from '../lib/utils'; + +const alertBannerStyles = tv({ + slots: { + root: 'w-full overflow-hidden rounded-lg border p-4 *:[a]:hover:underline **:[strong]:font-medium', + indicatorOuter: + 'me-3 grid size-8 place-content-center rounded-full border-2', + indicatorInner: 'grid size-6 place-content-center rounded-full border-2', + content: 'text-pretty', + }, + variants: { + intent: { + default: { + root: 'bg-muted/50 text-secondary-fg', + }, + info: { + root: 'bg-info-subtle text-info-subtle-fg **:[.text-muted-fg]:text-info-subtle-fg/70', + indicatorOuter: 'border-info-subtle-fg/40', + indicatorInner: 'border-info-subtle-fg/85', + }, + warning: { + root: 'bg-warning-subtle text-warning-subtle-fg **:[.text-muted-fg]:text-warning-subtle-fg/80', + indicatorOuter: 'border-warning-subtle-fg/40', + indicatorInner: 'border-warning-subtle-fg/85', + }, + danger: { + root: 'bg-danger-subtle text-danger-subtle-fg **:[.text-muted-fg]:text-danger-subtle-fg/80', + indicatorOuter: 'border-danger-subtle-fg/40', + indicatorInner: 'border-danger-subtle-fg/85', + }, + success: { + root: 'bg-success-subtle text-success-subtle-fg **:[.text-muted-fg]:text-success-subtle-fg/80', + indicatorOuter: 'border-success-subtle-fg/40', + indicatorInner: 'border-success-subtle-fg/85', + }, + }, + variant: { + default: { + root: 'grid grid-cols-[auto_1fr] text-base/6 backdrop-blur-2xl sm:text-sm/6', + content: 'group-has-data-[slot=icon]:col-start-2', + }, + banner: { + root: 'flex items-center gap-1 shadow-light', + content: 'flex min-w-0 items-center gap-1', + }, + }, + }, + compoundVariants: [ + { + variant: 'banner', + intent: 'warning', + class: { + root: 'border-primary-orange1 text-neutral-black [background:linear-gradient(rgba(255,255,255,0.92),rgba(255,255,255,0.92)),var(--color-primary-orange1)]', + }, + }, + { + variant: 'banner', + intent: 'danger', + class: { + root: 'border-functional-red text-neutral-black [background:linear-gradient(rgba(255,255,255,0.96),rgba(255,255,255,0.96)),var(--color-functional-red)]', + }, + }, + { + variant: 'banner', + intent: 'default', + class: { + root: 'border-neutral-gray2 bg-neutral-offWhite text-neutral-black', + }, + }, + ], + defaultVariants: { + intent: 'default', + variant: 'default', + }, +}); + +const iconMap: Record< + string, + React.ComponentType<{ className?: string }> | null +> = { + info: LuInfo, + warning: LuCircleAlert, + danger: LuCircleAlert, + success: LuCircleCheck, + default: null, +}; + +export interface AlertBannerProps + extends React.HtmlHTMLAttributes { + intent?: 'default' | 'info' | 'warning' | 'danger' | 'success'; + variant?: 'default' | 'banner'; + indicator?: boolean; + icon?: ReactNode; + contentClassName?: string; +} + +export function AlertBanner({ + indicator = true, + intent = 'default', + variant = 'default', + icon, + className, + contentClassName, + ...props +}: AlertBannerProps) { + const styles = alertBannerStyles({ intent, variant }); + const IconComponent = iconMap[intent] || null; + + return ( +
+ {variant === 'banner' ? ( + <> + + {icon ?? } + + + {props.children} + + + ) : ( + <> + {IconComponent && indicator && ( +
+
+ +
+
+ )} +
+ {props.children} +
+ + )} +
+ ); +} diff --git a/packages/ui/stories/AlertBanner.stories.tsx b/packages/ui/stories/AlertBanner.stories.tsx new file mode 100644 index 000000000..cb5c080c2 --- /dev/null +++ b/packages/ui/stories/AlertBanner.stories.tsx @@ -0,0 +1,89 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { LuTriangleAlert } from 'react-icons/lu'; + +import { AlertBanner } from '../src/components/AlertBanner'; + +const meta: Meta = { + title: 'AlertBanner', + component: AlertBanner, + tags: ['autodocs'], + args: { + variant: 'banner', + }, + argTypes: { + intent: { + control: 'select', + options: ['default', 'info', 'warning', 'danger', 'success'], + }, + variant: { + control: 'select', + options: ['default', 'banner'], + }, + children: { + control: 'text', + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Warning: Story = { + args: { + intent: 'warning', + children: 'This action requires your attention before proceeding.', + }, +}; + +export const Alert: Story = { + args: { + intent: 'danger', + children: 'There was a critical error processing your request.', + }, +}; + +export const Neutral: Story = { + args: { + intent: 'default', + children: 'Your session will expire in 5 minutes.', + }, +}; + +export const CustomIcon: Story = { + args: { + intent: 'warning', + icon: , + children: 'Warning with a custom triangle icon.', + }, +}; + +export const LongText: Story = { + args: { + intent: 'warning', + children: + 'This is a very long message that should be truncated with an ellipsis when it overflows the container width. It keeps going and going to demonstrate the text-overflow behavior of the AlertBanner component.', + }, +}; + +export const BannerVariants = () => ( +
+ + Warning: This action requires your attention. + + + Alert: There was a critical error processing your request. + + + Info: Your session will expire in 5 minutes. + +
+); + +export const DefaultVariant: Story = { + args: { + variant: 'default', + intent: 'warning', + children: 'This uses the default AlertBanner styling with indicator.', + }, +};