Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
140 changes: 140 additions & 0 deletions packages/ui/src/components/AlertBanner.tsx
Original file line number Diff line number Diff line change
@@ -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)]',
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this gradient is to tint the --color-primary-orange1 to the right shade... we should ideally use a color from our scale. But I checked the lightest orange in that scale and it's too dark. Our core color tokens should be updated, though not in this PR.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great. I think we'll need to update the Figma token as well since it's using this gradient.

},
},
{
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<HTMLDivElement> {
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 (
<div data-slot="note" className={cn(styles.root(), className)} {...props}>
{variant === 'banner' ? (
<>
<span className="shrink-0 [&>svg]:size-4">
{icon ?? <LuInfo className="size-4" />}
</span>
<span className="truncate text-sm leading-[1.5] font-normal">
{props.children}
</span>
</>
) : (
<>
{IconComponent && indicator && (
<div className={styles.indicatorOuter()}>
<div className={styles.indicatorInner()}>
<IconComponent className="size-5 shrink-0" />
</div>
</div>
)}
<div className={cn(styles.content(), contentClassName)}>
{props.children}
</div>
</>
)}
</div>
);
}
89 changes: 89 additions & 0 deletions packages/ui/stories/AlertBanner.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof AlertBanner> = {
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<typeof AlertBanner>;

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: <LuTriangleAlert className="size-4" />,
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 = () => (
<div className="flex w-96 flex-col gap-4">
<AlertBanner variant="banner" intent="warning">
Warning: This action requires your attention.
</AlertBanner>
<AlertBanner variant="banner" intent="danger">
Alert: There was a critical error processing your request.
</AlertBanner>
<AlertBanner variant="banner" intent="default">
Info: Your session will expire in 5 minutes.
</AlertBanner>
</div>
);

export const DefaultVariant: Story = {
args: {
variant: 'default',
intent: 'warning',
children: 'This uses the default AlertBanner styling with indicator.',
},
};
Loading