diff --git a/libs/react/ui/index.css b/libs/react/ui/index.css index 75c64b1d..57857035 100644 --- a/libs/react/ui/index.css +++ b/libs/react/ui/index.css @@ -268,7 +268,7 @@ --tag-error-icon: var(--color-red-500); --tag-error-text: var(--color-red-800); --tag-warning-icon: var(--color-orange-400); - --tag-warning-text: var(--color-orange-200); + --tag-warning-text: var(--color-orange-800); --tag-success-bg: var(--color-green-100); --tag-success-border: var(--color-green-200); --tag-success-text: var(--color-green-800); diff --git a/libs/react/ui/src/components/badge/badge.stories.tsx b/libs/react/ui/src/components/badge/badge.stories.tsx new file mode 100644 index 00000000..2f94c2c4 --- /dev/null +++ b/libs/react/ui/src/components/badge/badge.stories.tsx @@ -0,0 +1,468 @@ +import type {Meta, StoryObj} from '@storybook/react'; +import {Code} from 'components/typography'; +import React from 'react'; +import {Badge, IconBadge, StatusBadge, UserBadge} from '.'; + +const meta = { + title: 'Components/Badge', + component: Badge, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const AllVariants: Story = { + render: () => ( +
+ {/* STATUS BADGE */} +
+ + Status Badge + +
+ Badge + Badge + Badge + + Badge + + Badge + Badge +
+
+ + {/* USER BADGE */} +
+ + User Badge + +
+ + + +
+
+ + {/* ICON BADGE */} +
+ + Icon Badge + +
+ + + + + + +
+
+ + {/* BADGE - 2XS SIZE */} +
+ + Badge - 2XS Size + +
+ {/* Base */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+ + {/* With Right Icon */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+ + {/* With Left Icon */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+
+
+ + {/* BADGE - XS SIZE */} +
+ + Badge - XS Size + +
+ {/* Base */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+ + {/* With Right Icon */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+ + {/* With Left Icon */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+
+
+ + {/* BADGE - ROUNDED */} +
+ + Badge - Rounded + +
+ {/* Base - 2XS */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+ + {/* Base - XS */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+ + {/* With Right Icon - 2XS */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+ + {/* With Right Icon - XS */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+ + {/* With Left Icon - 2XS */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+ + {/* With Left Icon - XS */} +
+ + Badge + + + Badge + + + Badge + + + Badge + + + Badge + + + Badge + +
+
+
+ + {/* BETA BADGE */} +
+ + Beta Badge + +
+ + Beta + +
+
+
+ ), +}; + +// Interactive badges with click handlers +function InteractiveBadgesComponent() { + const [tags, setTags] = React.useState(['React', 'TypeScript', 'Next.js', 'Tailwind']); + + const removeTag = (tagToRemove: string) => { + setTags(tags.filter((tag) => tag !== tagToRemove)); + }; + + return ( +
+ {/* Removable tags */} +
+ + Interactive Badges - Removable Tags + +
+ {tags.map((tag) => ( + removeTag(tag)} + iconRightAriaLabel={`Remove ${tag} tag`} + > + {tag} + + ))} +
+
+ + {/* Different variants with interactive icons */} +
+ + Interactive Badges - Different Variants + +
+ alert('Success badge clicked!')} + iconRightAriaLabel="Remove success badge" + > + Completed + + alert('Warning badge clicked!')} + iconRightAriaLabel="Remove warning badge" + > + Pending + + alert('Error badge clicked!')} + iconRightAriaLabel="Remove error badge" + > + Failed + +
+
+ + {/* Non-interactive icons (static) */} +
+ + Static Icons (Non-interactive) + +
+ + Information + + + Verified + + + Premium + +
+
+
+ ); +} + +export const InteractiveBadges: Story = { + render: () => , +}; diff --git a/libs/react/ui/src/components/badge/badge.tsx b/libs/react/ui/src/components/badge/badge.tsx new file mode 100644 index 00000000..9b559aad --- /dev/null +++ b/libs/react/ui/src/components/badge/badge.tsx @@ -0,0 +1,147 @@ +import {Slot} from '@radix-ui/react-slot'; +import {cva, type VariantProps} from 'class-variance-authority'; +import {Icon, type IconName} from 'components/icon'; +import type {ComponentProps} from 'react'; +import {cn} from 'utils/cn'; + +export const badgeVariants = cva( + 'inline-flex select-none items-center justify-center font-medium transition-colors shrink-0 leading-20', + { + variants: { + variant: { + neutral: + 'bg-tag-neutral-bg text-tag-neutral-text border border-tag-neutral-border hover:bg-tag-neutral-bg-hover', + info: 'bg-tag-blue-bg text-tag-blue-text border border-tag-blue-border hover:bg-tag-blue-bg-hover', + feature: + 'bg-tag-purple-bg text-tag-purple-text border border-tag-purple-border hover:bg-tag-purple-bg-hover', + success: + 'bg-tag-success-bg text-tag-success-text border border-tag-success-border hover:bg-tag-success-bg-hover', + warning: + 'bg-tag-warning-bg text-tag-warning-text border border-tag-warning-border hover:bg-tag-warning-bg-hover', + error: + 'bg-tag-error-bg text-tag-error-text border border-tag-error-border hover:bg-tag-error-bg-hover', + }, + size: { + '2xs': 'h-20 px-6 text-xs gap-4', + xs: 'h-24 px-8 text-xs gap-6', + }, + radius: { + default: 'rounded-6', + rounded: 'rounded-full', + }, + }, + defaultVariants: { + variant: 'neutral', + size: '2xs', + radius: 'default', + }, + }, +); + +export type BadgeVariant = VariantProps['variant']; + +type BaseBadgeProps = ComponentProps<'span'> & + VariantProps & { + asChild?: boolean; + }; + +type BadgePropsWithLeftClick = BaseBadgeProps & { + iconLeft: IconName; + onIconLeftClick: (e: React.MouseEvent) => void; + iconLeftAriaLabel: string; + iconRight?: IconName; + onIconRightClick?: (e: React.MouseEvent) => void; + iconRightAriaLabel?: string; +}; + +type BadgePropsWithRightClick = BaseBadgeProps & { + iconRight: IconName; + onIconRightClick: (e: React.MouseEvent) => void; + iconRightAriaLabel: string; + iconLeft?: IconName; + onIconLeftClick?: (e: React.MouseEvent) => void; + iconLeftAriaLabel?: string; +}; + +type BadgePropsWithBothClicks = BaseBadgeProps & { + iconLeft: IconName; + onIconLeftClick: (e: React.MouseEvent) => void; + iconLeftAriaLabel: string; + iconRight: IconName; + onIconRightClick: (e: React.MouseEvent) => void; + iconRightAriaLabel: string; +}; + +type BadgePropsWithoutClicks = BaseBadgeProps & { + iconLeft?: IconName; + iconRight?: IconName; + onIconLeftClick?: never; + onIconRightClick?: never; + iconLeftAriaLabel?: never; + iconRightAriaLabel?: never; +}; + +export type BadgeProps = + | BadgePropsWithLeftClick + | BadgePropsWithRightClick + | BadgePropsWithBothClicks + | BadgePropsWithoutClicks; + +export function Badge({ + className, + variant, + size, + radius, + asChild = false, + children, + iconLeft, + iconRight, + onIconLeftClick, + onIconRightClick, + iconLeftAriaLabel, + iconRightAriaLabel, + ...props +}: BadgeProps) { + const Comp = asChild ? Slot : 'span'; + + const renderIcon = ( + iconName: IconName, + position: 'left' | 'right', + onClick?: (e: React.MouseEvent) => void, + ariaLabel?: string, + ) => { + const isInteractive = Boolean(onClick); + + if (isInteractive) { + if (!ariaLabel) { + // biome-ignore lint/suspicious/noConsole: Development warning for accessibility + console.warn( + `Badge: Missing aria-label for interactive ${position} icon. Please provide icon${position === 'left' ? 'Left' : 'Right'}AriaLabel prop.`, + ); + + return null; + } + + return ( + + ); + } + + return ; + }; + + return ( + + {iconLeft && renderIcon(iconLeft, 'left', onIconLeftClick, iconLeftAriaLabel)} + {children} + {iconRight && renderIcon(iconRight, 'right', onIconRightClick, iconRightAriaLabel)} + + ); +} diff --git a/libs/react/ui/src/components/badge/icon-badge.tsx b/libs/react/ui/src/components/badge/icon-badge.tsx new file mode 100644 index 00000000..369150a7 --- /dev/null +++ b/libs/react/ui/src/components/badge/icon-badge.tsx @@ -0,0 +1,43 @@ +import {Icon, type IconName} from 'components/icon'; +import type {ComponentProps} from 'react'; +import {cn} from 'utils/cn'; + +export type IconBadgeVariant = 'neutral' | 'info' | 'feature' | 'success' | 'primary' | 'error'; + +export type IconBadgeProps = ComponentProps<'span'> & { + variant?: IconBadgeVariant; + name?: IconName; +}; + +const variantStyles: Record = { + neutral: 'bg-tag-neutral-bg border-tag-neutral-border', + info: 'bg-tag-blue-bg border-tag-blue-border', + feature: 'bg-tag-purple-bg border-tag-purple-border', + success: 'bg-tag-success-bg border-tag-success-border', + primary: 'bg-tag-warning-bg border-tag-warning-border', + error: 'bg-tag-error-bg border-tag-error-border', +}; + +const iconColorStyles: Record = { + neutral: 'text-tag-neutral-icon', + info: 'text-tag-blue-icon', + feature: 'text-tag-purple-icon', + success: 'text-tag-success-icon', + primary: 'text-tag-warning-icon', + error: 'text-tag-error-icon', +}; + +export function IconBadge({className, variant = 'neutral', name, ...props}: IconBadgeProps) { + return ( + + {name && } + + ); +} diff --git a/libs/react/ui/src/components/badge/index.ts b/libs/react/ui/src/components/badge/index.ts new file mode 100644 index 00000000..cb6ab6af --- /dev/null +++ b/libs/react/ui/src/components/badge/index.ts @@ -0,0 +1,4 @@ +export {Badge, type BadgeProps, type BadgeVariant, badgeVariants} from './badge'; +export {IconBadge, type IconBadgeProps, type IconBadgeVariant} from './icon-badge'; +export {StatusBadge, type StatusBadgeProps} from './status-badge'; +export {UserBadge, type UserBadgeProps} from './user-badge'; diff --git a/libs/react/ui/src/components/badge/status-badge.tsx b/libs/react/ui/src/components/badge/status-badge.tsx new file mode 100644 index 00000000..6433612a --- /dev/null +++ b/libs/react/ui/src/components/badge/status-badge.tsx @@ -0,0 +1,43 @@ +import type {ComponentProps} from 'react'; +import {cn} from 'utils/cn'; +import {badgeVariants} from './badge'; + +export type StatusBadgeProps = ComponentProps<'span'> & { + variant?: StatusVariant; + dotClassName?: string; +}; + +type StatusVariant = 'neutral' | 'info' | 'feature' | 'success' | 'warning' | 'error'; + +const dotVariantStyles: Record = { + neutral: 'bg-tag-neutral-icon', + info: 'bg-tag-blue-icon', + feature: 'bg-tag-purple-icon', + success: 'bg-tag-success-icon', + warning: 'bg-tag-warning-icon', + error: 'bg-tag-error-icon', +}; + +export function StatusBadge({ + className, + variant = 'neutral', + children, + dotClassName, + ...props +}: StatusBadgeProps) { + return ( + + + {children} + + ); +} diff --git a/libs/react/ui/src/components/badge/user-badge.tsx b/libs/react/ui/src/components/badge/user-badge.tsx new file mode 100644 index 00000000..6fffc6fa --- /dev/null +++ b/libs/react/ui/src/components/badge/user-badge.tsx @@ -0,0 +1,34 @@ +import {Avatar} from 'components/avatar'; +import type {ComponentProps} from 'react'; +import {cn} from 'utils/cn'; + +export type UserBadgeProps = ComponentProps<'button'> & { + name: string; + avatarSrc?: string; + avatarFallback?: string; +}; + +export function UserBadge({className, name, avatarSrc, avatarFallback, ...props}: UserBadgeProps) { + return ( + + ); +} diff --git a/libs/react/ui/src/components/icon/icon.tsx b/libs/react/ui/src/components/icon/icon.tsx index 918bd35a..165dc6ec 100644 --- a/libs/react/ui/src/components/icon/icon.tsx +++ b/libs/react/ui/src/components/icon/icon.tsx @@ -5,6 +5,7 @@ import { RiFileCopyLine, RiGithubFill, RiGoogleFill, + RiHomeSmileFill, RiImageAddFill, RiInformationFill, RiMicrosoftFill, @@ -53,6 +54,7 @@ const iconsMap = { subtractLine: RiSubtractLine, info: RiInformationFill, money: RiMoneyDollarCircleLine, + homeSmile: RiHomeSmileFill, copy: RiFileCopyLine, } as const satisfies Record; diff --git a/libs/react/ui/src/components/index.ts b/libs/react/ui/src/components/index.ts index fe375a2e..5b2ed922 100644 --- a/libs/react/ui/src/components/index.ts +++ b/libs/react/ui/src/components/index.ts @@ -1,5 +1,6 @@ export * from './alert'; export * from './avatar'; +export * from './badge'; export * from './button'; export * from './checkbox'; export * from './code-block';