Skip to content

Commit fc53b5d

Browse files
Merge pull request #699 from owohdora-stack/chore-badge-system-408
Add Badge component and integrate into DashboardFilters; update exports
2 parents fd659df + d5923ca commit fc53b5d

5 files changed

Lines changed: 186 additions & 16 deletions

File tree

src/components/dashboard/DashboardFilters.tsx

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
'use client';
88

99
import React, { useState, useCallback, useMemo } from 'react';
10-
import { Filter, X, RotateCcw, LifeBuoy } from 'lucide-react';
10+
import { Filter, RotateCcw, LifeBuoy } from 'lucide-react';
11+
import { Badge } from '@/components';
1112
import { TimeRange, AggregationType, CHART_COLOR_PALETTE } from '@/utils/visualizationUtils';
1213
import type { DashboardFiltersState } from '@/hooks/useDashboardData';
1314
import { useInternationalization } from '@/hooks/useInternationalization';
@@ -146,23 +147,18 @@ export const DashboardFilters = React.memo<DashboardFiltersProps>(
146147
)}
147148
>
148149
{filters.timeRange !== '30d' && (
149-
<span
150+
<Badge
150151
role="listitem"
151-
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-full bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300"
152+
variant="default"
153+
onRemove={() => onFiltersChange({ timeRange: '30d' })}
154+
removeLabel={translateWithFallback(
155+
t,
156+
'dashboard.analytics.filters.removeTimeRange',
157+
'Remove time range filter',
158+
)}
152159
>
153160
{timeRangeOptions.find((option) => option.value === filters.timeRange)?.label}
154-
<button
155-
onClick={() => onFiltersChange({ timeRange: '30d' })}
156-
aria-label={translateWithFallback(
157-
t,
158-
'dashboard.analytics.filters.removeTimeRange',
159-
'Remove time range filter',
160-
)}
161-
className="hover:text-red-500 transition-colors"
162-
>
163-
<X className="w-3 h-3" />
164-
</button>
165-
</span>
161+
</Badge>
166162
)}
167163
{filters.categories.map((cat, i) => (
168164
<span
@@ -182,7 +178,9 @@ export const DashboardFilters = React.memo<DashboardFiltersProps>(
182178
)}
183179
className="hover:opacity-75 transition-opacity"
184180
>
185-
<X className="w-3 h-3" />
181+
<span className="hover:opacity-75 transition-opacity w-3 h-3 inline-flex items-center justify-center">
182+
&times;
183+
</span>
186184
</button>
187185
</span>
188186
))}

src/components/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
*/
66

77
export * from './ui/Accordion';
8+
export { Badge, badgeVariants } from './ui/Badge';
9+
export type { BadgeProps } from './ui/Badge';
810
export { Button, buttonVariants } from './ui/Button';
911
export type { ButtonProps } from './ui/Button';
1012
export { ButtonGroup } from './ui/ButtonGroup';

src/components/ui/Badge.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
'use client';
2+
3+
import React from 'react';
4+
import { cva, type VariantProps } from 'class-variance-authority';
5+
import { cn } from '@/lib/utils';
6+
import { X } from 'lucide-react';
7+
8+
const badgeVariants = cva(
9+
'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',
10+
{
11+
variants: {
12+
variant: {
13+
default: 'bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-300',
14+
success: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300',
15+
warning: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/40 dark:text-yellow-300',
16+
danger: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
17+
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
18+
outline: 'border border-gray-300 text-gray-700 dark:border-gray-600 dark:text-gray-300',
19+
},
20+
size: {
21+
sm: 'px-1.5 py-0.5 text-xs',
22+
md: 'px-2.5 py-0.5 text-xs',
23+
lg: 'px-3 py-1 text-sm',
24+
},
25+
},
26+
defaultVariants: {
27+
variant: 'default',
28+
size: 'md',
29+
},
30+
},
31+
);
32+
33+
export interface BadgeProps
34+
extends React.HTMLAttributes<HTMLSpanElement>,
35+
VariantProps<typeof badgeVariants> {
36+
onRemove?: () => void;
37+
removeLabel?: string;
38+
}
39+
40+
function Badge({
41+
className,
42+
variant,
43+
size,
44+
children,
45+
onRemove,
46+
removeLabel = 'Remove',
47+
...props
48+
}: BadgeProps) {
49+
return (
50+
<span className={cn(badgeVariants({ variant, size }), className)} {...props}>
51+
{children}
52+
{onRemove && (
53+
<button
54+
onClick={(e) => {
55+
e.stopPropagation();
56+
onRemove();
57+
}}
58+
aria-label={removeLabel}
59+
className="hover:text-red-500 transition-colors flex-shrink-0"
60+
>
61+
<X className="w-3 h-3" />
62+
</button>
63+
)}
64+
</span>
65+
);
66+
}
67+
68+
Badge.displayName = 'Badge';
69+
70+
export { Badge, badgeVariants };
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
import React from 'react';
2+
import { render, screen, fireEvent } from '@testing-library/react';
3+
import { Badge } from '../Badge';
4+
5+
describe('Badge', () => {
6+
it('renders with default props', () => {
7+
render(<Badge>Default</Badge>);
8+
const badge = screen.getByText('Default');
9+
expect(badge).toBeInTheDocument();
10+
expect(badge.tagName).toBe('SPAN');
11+
});
12+
13+
it('renders variant classes correctly', () => {
14+
const { rerender } = render(<Badge variant="default">Default</Badge>);
15+
expect(screen.getByText('Default')).toHaveClass('bg-gray-100');
16+
17+
rerender(<Badge variant="success">Success</Badge>);
18+
expect(screen.getByText('Success')).toHaveClass('bg-green-100');
19+
20+
rerender(<Badge variant="warning">Warning</Badge>);
21+
expect(screen.getByText('Warning')).toHaveClass('bg-yellow-100');
22+
23+
rerender(<Badge variant="danger">Danger</Badge>);
24+
expect(screen.getByText('Danger')).toHaveClass('bg-red-100');
25+
26+
rerender(<Badge variant="info">Info</Badge>);
27+
expect(screen.getByText('Info')).toHaveClass('bg-blue-100');
28+
29+
rerender(<Badge variant="outline">Outline</Badge>);
30+
expect(screen.getByText('Outline')).toHaveClass('border');
31+
});
32+
33+
it('renders size classes correctly', () => {
34+
const { rerender } = render(<Badge size="sm">Small</Badge>);
35+
expect(screen.getByText('Small')).toHaveClass('px-1.5');
36+
37+
rerender(<Badge size="md">Medium</Badge>);
38+
expect(screen.getByText('Medium')).toHaveClass('px-2.5');
39+
40+
rerender(<Badge size="lg">Large</Badge>);
41+
expect(screen.getByText('Large')).toHaveClass('px-3');
42+
});
43+
44+
it('renders a remove button when onRemove is provided', () => {
45+
const onRemove = vi.fn();
46+
render(<Badge onRemove={onRemove}>Dismissible</Badge>);
47+
const removeBtn = screen.getByRole('button');
48+
expect(removeBtn).toBeInTheDocument();
49+
fireEvent.click(removeBtn);
50+
expect(onRemove).toHaveBeenCalledTimes(1);
51+
});
52+
53+
it('calls onRemove with stopPropagation on remove click', () => {
54+
const onRemove = vi.fn();
55+
render(
56+
<div onClick={vi.fn()}>
57+
<Badge onRemove={onRemove}>Nested</Badge>
58+
</div>,
59+
);
60+
const removeBtn = screen.getByRole('button');
61+
fireEvent.click(removeBtn);
62+
expect(onRemove).toHaveBeenCalledTimes(1);
63+
});
64+
65+
it('does not render remove button when onRemove is not provided', () => {
66+
render(<Badge>No Remove</Badge>);
67+
expect(screen.queryByRole('button')).not.toBeInTheDocument();
68+
});
69+
70+
it('uses custom remove aria-label', () => {
71+
const onRemove = vi.fn();
72+
render(
73+
<Badge onRemove={onRemove} removeLabel="Custom remove label">
74+
Custom
75+
</Badge>,
76+
);
77+
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Custom remove label');
78+
});
79+
80+
it('applies custom className', () => {
81+
render(<Badge className="custom-badge">Custom Class</Badge>);
82+
expect(screen.getByText('Custom Class')).toHaveClass('custom-badge');
83+
});
84+
85+
it('supports additional HTML span attributes', () => {
86+
render(
87+
<Badge role="status" aria-label="Status badge">
88+
Aria
89+
</Badge>,
90+
);
91+
const badge = screen.getByRole('status');
92+
expect(badge).toHaveAttribute('aria-label', 'Status badge');
93+
});
94+
95+
it('has displayName', () => {
96+
expect(Badge.displayName).toBe('Badge');
97+
});
98+
});

src/components/ui/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ export {
99
type BreadcrumbItem,
1010
type BreadcrumbsProps,
1111
} from './Breadcrumbs';
12+
export { Badge, badgeVariants } from './Badge';
13+
export type { BadgeProps } from './Badge';
1214
export { Button } from './Button';
1315
export { ButtonGroup } from './ButtonGroup';
1416
export { EmptyState } from './EmptyState';

0 commit comments

Comments
 (0)