From da46bfbd3c38be984781ba4ac9c7bfe66c9d3615 Mon Sep 17 00:00:00 2001 From: Kyle Nguyen Date: Tue, 11 Nov 2025 16:22:32 +0700 Subject: [PATCH 1/2] feat(ui): add Badge component --- libs/react/ui/index.css | 2 +- .../ui/src/components/badge/badge.stories.tsx | 468 ++++++++++++++++++ libs/react/ui/src/components/badge/badge.tsx | 139 ++++++ .../ui/src/components/badge/icon-badge.tsx | 43 ++ libs/react/ui/src/components/badge/index.ts | 4 + .../ui/src/components/badge/status-badge.tsx | 40 ++ .../ui/src/components/badge/user-badge.tsx | 34 ++ libs/react/ui/src/components/icon/icon.tsx | 2 + libs/react/ui/src/components/index.ts | 1 + 9 files changed, 732 insertions(+), 1 deletion(-) create mode 100644 libs/react/ui/src/components/badge/badge.stories.tsx create mode 100644 libs/react/ui/src/components/badge/badge.tsx create mode 100644 libs/react/ui/src/components/badge/icon-badge.tsx create mode 100644 libs/react/ui/src/components/badge/index.ts create mode 100644 libs/react/ui/src/components/badge/status-badge.tsx create mode 100644 libs/react/ui/src/components/badge/user-badge.tsx diff --git a/libs/react/ui/index.css b/libs/react/ui/index.css index a28c88e7..326e780d 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..dc7f407f --- /dev/null +++ b/libs/react/ui/src/components/badge/badge.tsx @@ -0,0 +1,139 @@ +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; + iconLeft?: IconName; + iconRight?: IconName; + }; + +type BadgePropsWithLeftClick = BaseBadgeProps & { + onIconLeftClick: (e: React.MouseEvent) => void; + iconLeftAriaLabel: string; + onIconRightClick?: (e: React.MouseEvent) => void; + iconRightAriaLabel?: string; +}; + +type BadgePropsWithRightClick = BaseBadgeProps & { + onIconRightClick: (e: React.MouseEvent) => void; + iconRightAriaLabel: string; + onIconLeftClick?: (e: React.MouseEvent) => void; + iconLeftAriaLabel?: string; +}; + +type BadgePropsWithBothClicks = BaseBadgeProps & { + onIconLeftClick: (e: React.MouseEvent) => void; + iconLeftAriaLabel: string; + onIconRightClick: (e: React.MouseEvent) => void; + iconRightAriaLabel: string; +}; + +type BadgePropsWithoutClicks = BaseBadgeProps & { + onIconLeftClick?: never; + onIconRightClick?: never; + iconLeftAriaLabel?: string; + iconRightAriaLabel?: string; +}; + +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 ( + + ); + } + + 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..c9cb78d9 --- /dev/null +++ b/libs/react/ui/src/components/badge/status-badge.tsx @@ -0,0 +1,40 @@ +import type {ComponentProps} from 'react'; +import {cn} from 'utils/cn'; +import {type BadgeVariant, badgeVariants} from './badge'; + +export type StatusBadgeProps = ComponentProps<'span'> & { + variant?: BadgeVariant; + 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) { + const variantKey = variant ?? 'neutral'; + 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 6ae50318..92989735 100644 --- a/libs/react/ui/src/components/icon/icon.tsx +++ b/libs/react/ui/src/components/icon/icon.tsx @@ -4,6 +4,7 @@ import { RiCloseLine, RiGithubFill, RiGoogleFill, + RiHomeSmileFill, RiImageAddFill, RiInformationFill, RiMicrosoftFill, @@ -52,6 +53,7 @@ const iconsMap = { subtractLine: RiSubtractLine, info: RiInformationFill, money: RiMoneyDollarCircleLine, + homeSmile: RiHomeSmileFill, } as const satisfies Record; export type IconName = keyof typeof iconsMap; diff --git a/libs/react/ui/src/components/index.ts b/libs/react/ui/src/components/index.ts index b22681ce..3229a3f8 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 './dynamic-item'; From 3d21ce8f17caa86718d669ff2b5d3b3127d9d1ef Mon Sep 17 00:00:00 2001 From: Kyle Nguyen Date: Tue, 11 Nov 2025 16:46:08 +0700 Subject: [PATCH 2/2] refactor(ui): enhance Badge component props for better click handling --- libs/react/ui/src/components/badge/badge.tsx | 18 +++++++++++++----- .../ui/src/components/badge/status-badge.tsx | 11 +++++++---- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/libs/react/ui/src/components/badge/badge.tsx b/libs/react/ui/src/components/badge/badge.tsx index dc7f407f..9b559aad 100644 --- a/libs/react/ui/src/components/badge/badge.tsx +++ b/libs/react/ui/src/components/badge/badge.tsx @@ -43,36 +43,42 @@ export type BadgeVariant = VariantProps['variant']; type BaseBadgeProps = ComponentProps<'span'> & VariantProps & { asChild?: boolean; - iconLeft?: IconName; - iconRight?: IconName; }; 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?: string; - iconRightAriaLabel?: string; + iconLeftAriaLabel?: never; + iconRightAriaLabel?: never; }; export type BadgeProps = @@ -112,6 +118,8 @@ export function Badge({ console.warn( `Badge: Missing aria-label for interactive ${position} icon. Please provide icon${position === 'left' ? 'Left' : 'Right'}AriaLabel prop.`, ); + + return null; } return ( @@ -130,7 +138,7 @@ export function Badge({ }; return ( - + {iconLeft && renderIcon(iconLeft, 'left', onIconLeftClick, iconLeftAriaLabel)} {children} {iconRight && renderIcon(iconRight, 'right', onIconRightClick, iconRightAriaLabel)} diff --git a/libs/react/ui/src/components/badge/status-badge.tsx b/libs/react/ui/src/components/badge/status-badge.tsx index c9cb78d9..6433612a 100644 --- a/libs/react/ui/src/components/badge/status-badge.tsx +++ b/libs/react/ui/src/components/badge/status-badge.tsx @@ -1,9 +1,9 @@ import type {ComponentProps} from 'react'; import {cn} from 'utils/cn'; -import {type BadgeVariant, badgeVariants} from './badge'; +import {badgeVariants} from './badge'; export type StatusBadgeProps = ComponentProps<'span'> & { - variant?: BadgeVariant; + variant?: StatusVariant; dotClassName?: string; }; @@ -25,14 +25,17 @@ export function StatusBadge({ dotClassName, ...props }: StatusBadgeProps) { - const variantKey = variant ?? 'neutral'; return ( {children}