From 316e773a2fb57db2d34c938043ee2631e9666de5 Mon Sep 17 00:00:00 2001 From: Owoh Chidubem Alexander Date: Fri, 26 Jun 2026 10:18:58 +0100 Subject: [PATCH 1/2] Add Badge component and integrate into DashboardFilters; update exports --- src/components/dashboard/DashboardFilters.tsx | 30 +++--- src/components/index.ts | 2 + src/components/ui/Badge.tsx | 79 +++++++++++++++ src/components/ui/__tests__/Badge.test.tsx | 98 +++++++++++++++++++ src/components/ui/index.ts | 2 + 5 files changed, 195 insertions(+), 16 deletions(-) create mode 100644 src/components/ui/Badge.tsx create mode 100644 src/components/ui/__tests__/Badge.test.tsx diff --git a/src/components/dashboard/DashboardFilters.tsx b/src/components/dashboard/DashboardFilters.tsx index 03ddc21a..f800b51b 100644 --- a/src/components/dashboard/DashboardFilters.tsx +++ b/src/components/dashboard/DashboardFilters.tsx @@ -7,7 +7,8 @@ 'use client'; import React, { useState, useCallback, useMemo } from 'react'; -import { Filter, X, RotateCcw, LifeBuoy } from 'lucide-react'; +import { Filter, RotateCcw, LifeBuoy } from 'lucide-react'; +import { Badge } from '@/components'; import { TimeRange, AggregationType, CHART_COLOR_PALETTE } from '@/utils/visualizationUtils'; import type { DashboardFiltersState } from '@/hooks/useDashboardData'; import { useInternationalization } from '@/hooks/useInternationalization'; @@ -146,23 +147,18 @@ export const DashboardFilters = React.memo( )} > {filters.timeRange !== '30d' && ( - onFiltersChange({ timeRange: '30d' })} + removeLabel={translateWithFallback( + t, + 'dashboard.analytics.filters.removeTimeRange', + 'Remove time range filter', + )} > {timeRangeOptions.find((option) => option.value === filters.timeRange)?.label} - - + )} {filters.categories.map((cat, i) => ( ( )} className="hover:opacity-75 transition-opacity" > - + + × + ))} diff --git a/src/components/index.ts b/src/components/index.ts index 490b771d..c2f79075 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,6 +5,8 @@ */ export * from './ui/Accordion'; +export { Badge, badgeVariants } from './ui/Badge'; +export type { BadgeProps } from './ui/Badge'; export { Button, buttonVariants } from './ui/Button'; export type { ButtonProps } from './ui/Button'; export { ButtonGroup } from './ui/ButtonGroup'; diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx new file mode 100644 index 00000000..24f08dd4 --- /dev/null +++ b/src/components/ui/Badge.tsx @@ -0,0 +1,79 @@ +'use client'; + +import React from 'react'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { cn } from '@/lib/utils'; +import { X } from 'lucide-react'; + +const badgeVariants = cva( + 'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500', + { + variants: { + variant: { + default: + 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', + success: + 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + warning: + 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', + danger: + 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + info: + 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + outline: + 'border border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300', + }, + size: { + sm: 'px-1.5 py-0.5 text-xs', + md: 'px-2.5 py-0.5 text-xs', + lg: 'px-3 py-1 text-sm', + }, + }, + defaultVariants: { + variant: 'default', + size: 'md', + }, + }, +); + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps { + onRemove?: () => void; + removeLabel?: string; +} + +function Badge({ + className, + variant, + size, + children, + onRemove, + removeLabel = 'Remove', + ...props +}: BadgeProps) { + return ( + + {children} + {onRemove && ( + + )} + + ); +} + +Badge.displayName = 'Badge'; + +export { Badge, badgeVariants }; diff --git a/src/components/ui/__tests__/Badge.test.tsx b/src/components/ui/__tests__/Badge.test.tsx new file mode 100644 index 00000000..2d6132a6 --- /dev/null +++ b/src/components/ui/__tests__/Badge.test.tsx @@ -0,0 +1,98 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { Badge } from '../Badge'; + +describe('Badge', () => { + it('renders with default props', () => { + render(Default); + const badge = screen.getByText('Default'); + expect(badge).toBeInTheDocument(); + expect(badge.tagName).toBe('SPAN'); + }); + + it('renders variant classes correctly', () => { + const { rerender } = render(Default); + expect(screen.getByText('Default')).toHaveClass('bg-gray-100'); + + rerender(Success); + expect(screen.getByText('Success')).toHaveClass('bg-green-100'); + + rerender(Warning); + expect(screen.getByText('Warning')).toHaveClass('bg-yellow-100'); + + rerender(Danger); + expect(screen.getByText('Danger')).toHaveClass('bg-red-100'); + + rerender(Info); + expect(screen.getByText('Info')).toHaveClass('bg-blue-100'); + + rerender(Outline); + expect(screen.getByText('Outline')).toHaveClass('border'); + }); + + it('renders size classes correctly', () => { + const { rerender } = render(Small); + expect(screen.getByText('Small')).toHaveClass('px-1.5'); + + rerender(Medium); + expect(screen.getByText('Medium')).toHaveClass('px-2.5'); + + rerender(Large); + expect(screen.getByText('Large')).toHaveClass('px-3'); + }); + + it('renders a remove button when onRemove is provided', () => { + const onRemove = vi.fn(); + render(Dismissible); + const removeBtn = screen.getByRole('button'); + expect(removeBtn).toBeInTheDocument(); + fireEvent.click(removeBtn); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('calls onRemove with stopPropagation on remove click', () => { + const onRemove = vi.fn(); + render( +
+ Nested +
, + ); + const removeBtn = screen.getByRole('button'); + fireEvent.click(removeBtn); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + + it('does not render remove button when onRemove is not provided', () => { + render(No Remove); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); + + it('uses custom remove aria-label', () => { + const onRemove = vi.fn(); + render( + + Custom + , + ); + expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Custom remove label'); + }); + + it('applies custom className', () => { + render(Custom Class); + expect(screen.getByText('Custom Class')).toHaveClass('custom-badge'); + }); + + it('supports additional HTML span attributes', () => { + render( + + Aria + , + ); + const badge = screen.getByRole('status'); + expect(badge).toHaveAttribute('aria-label', 'Status badge'); + }); + + it('has displayName', () => { + expect(Badge.displayName).toBe('Badge'); + }); +}); diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index 482c47c9..58772e71 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -9,6 +9,8 @@ export { type BreadcrumbItem, type BreadcrumbsProps, } from './Breadcrumbs'; +export { Badge, badgeVariants } from './Badge'; +export type { BadgeProps } from './Badge'; export { Button } from './Button'; export { ButtonGroup } from './ButtonGroup'; export { EmptyState } from './EmptyState'; From d5923ca64ef2cf2464b6e1bbc3c6378aae9cfd33 Mon Sep 17 00:00:00 2001 From: Owoh Chidubem Alexander Date: Sun, 28 Jun 2026 09:53:30 +0100 Subject: [PATCH 2/2] Refactor badge component styles and clean up test files for consistency --- src/components/ui/Badge.tsx | 23 +++++++---------------- src/lib/settings/__tests__/store.test.ts | 2 +- src/store/cmsStore.test.ts | 10 +++++----- src/store/cmsStore.ts | 14 ++++++++------ 4 files changed, 21 insertions(+), 28 deletions(-) diff --git a/src/components/ui/Badge.tsx b/src/components/ui/Badge.tsx index 24f08dd4..89d8f0af 100644 --- a/src/components/ui/Badge.tsx +++ b/src/components/ui/Badge.tsx @@ -10,18 +10,12 @@ const badgeVariants = cva( { variants: { variant: { - default: - 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', - success: - 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', - warning: - 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', - danger: - 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', - info: - 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', - outline: - 'border border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300', + default: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300', + success: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', + warning: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300', + danger: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', + info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', + outline: 'border border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300', }, size: { sm: 'px-1.5 py-0.5 text-xs', @@ -53,10 +47,7 @@ function Badge({ ...props }: BadgeProps) { return ( - + {children} {onRemove && (