Skip to content
Open
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
18 changes: 18 additions & 0 deletions design-system/documentation/status-chip.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,24 @@ import these unions instead of declaring their own. When adding a new
`VaultStatus`, add it here and to `STATUS_CONFIG` in `StatusChip.tsx` so the
chip can render it.

## Badge primitive

A generic `Badge` primitive is now available for count and tier indicators. It accepts `tone` (`neutral | info | success | warning | danger`), `size` (`sm | md | lg`), and an optional numeric `count` prop.

```tsx
import { Badge } from '../components/Badge';

<Badge tone="warning" size="sm">
Due soon
</Badge>

<Badge count={3} tone="info" size="sm">
3
</Badge>
```

Numeric badges automatically expose an accessible `aria-label` of the form `Count: N`, which makes count-based indicators suitable for screen readers without requiring extra wiring.

## Accessibility

- The component exposes its status using an implicit `role="status"` and includes an accessible `aria-label` covering the underlying meaning, regardless of whether a custom `label` string is passed in or not.
Expand Down
78 changes: 78 additions & 0 deletions src/components/Badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react';

export type BadgeTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
export type BadgeSize = 'sm' | 'md' | 'lg';

export interface BadgeProps extends React.HTMLAttributes<HTMLSpanElement> {
tone?: BadgeTone;
size?: BadgeSize;
count?: number;
children: React.ReactNode;
}

const TONE_STYLES: Record<BadgeTone, { color: string; background: string }> = {
neutral: {
color: 'var(--muted)',
background: 'color-mix(in srgb, var(--muted) 10%, transparent)',
},
info: {
color: 'var(--info)',
background: 'color-mix(in srgb, var(--info) 10%, transparent)',
},
success: {
color: 'var(--success)',
background: 'color-mix(in srgb, var(--success) 10%, transparent)',
},
warning: {
color: 'var(--warning)',
background: 'color-mix(in srgb, var(--warning) 10%, transparent)',
},
danger: {
color: 'var(--danger)',
background: 'color-mix(in srgb, var(--danger) 10%, transparent)',
},
};

const SIZE_STYLES: Record<BadgeSize, { padding: string; fontSize: string }> = {
sm: { padding: '2px 8px', fontSize: '11px' },
md: { padding: '2px 10px', fontSize: '12px' },
lg: { padding: '4px 12px', fontSize: '14px' },
};

export const Badge = React.forwardRef<HTMLSpanElement, BadgeProps>(function Badge({
tone = 'neutral',
size = 'md',
count,
className = '',
children,
ariaLabel,
...props
}, ref) {
const style = TONE_STYLES[tone];
const sizeStyle = SIZE_STYLES[size];
const accessibleLabel = ariaLabel ?? (typeof count === 'number' ? `Count: ${count}` : undefined);

return (
<span
ref={ref}
className={`badge ${className}`.trim()}
style={{
background: style.background,
color: style.color,
border: `1px solid ${style.color}`,
borderRadius: 'var(--radius-full)',
fontWeight: 600,
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: 4,
...sizeStyle,
}}
aria-label={accessibleLabel}
{...props}
>
{children}
</span>
);
});
53 changes: 12 additions & 41 deletions src/components/VaultCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { Link } from 'react-router-dom';
import { Text } from './Text';
import { VaultProgressBar } from './VaultProgressBar';
import { CountdownDeadline } from './CountdownDeadline';
import { Badge } from './Badge';
import type { VaultStatus } from '../types/vault';

export type { VaultStatus };
Expand Down Expand Up @@ -56,56 +57,26 @@ const URGENCY_BADGE_CONFIG = {
function UrgencyBadge({ tier }: { tier: UrgencyTier }) {
if (tier === 'safe') return null;
const config = URGENCY_BADGE_CONFIG[tier];
const tone = tier === 'critical' ? 'danger' : 'warning';

return (
<span
aria-label={config.ariaLabel}
style={{
background: config.bg,
color: config.fg,
border: `1px solid ${config.fg}`,
borderRadius: 'var(--radius-full)',
padding: '2px 8px',
fontSize: 11,
fontWeight: 600,
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
}}
>
<Badge tone={tone} size="sm" ariaLabel={config.ariaLabel}>
{config.label}
</span>
</Badge>
);
}

function StatusBadge({ status }: { status: VaultStatus }) {
const config: Record<VaultStatus, { label: string; bg: string; fg: string }> = {
active: { label: 'Active', bg: 'var(--accent-transparent)', fg: 'var(--accent)' },
pending_validation: { label: 'Pending', bg: 'var(--warning-transparent)', fg: 'var(--warning)' },
completed: { label: 'Completed', bg: 'var(--success-transparent)', fg: 'var(--success)' },
failed: { label: 'Failed', bg: 'var(--danger-transparent)', fg: 'var(--danger)' },
cancelled: { label: 'Cancelled', bg: 'rgba(156,163,175,0.1)', fg: 'var(--muted)' },
const config: Record<VaultStatus, { label: string; tone: 'neutral' | 'info' | 'success' | 'warning' | 'danger' }> = {
active: { label: 'Active', tone: 'info' },
pending_validation: { label: 'Pending', tone: 'warning' },
completed: { label: 'Completed', tone: 'success' },
failed: { label: 'Failed', tone: 'danger' },
cancelled: { label: 'Cancelled', tone: 'neutral' },
};
const badge = config[status];

return (
<span
style={{
background: badge.bg,
color: badge.fg,
border: `1px solid ${badge.fg}`,
borderRadius: 'var(--radius-full)',
padding: '2px 8px',
fontSize: 11,
fontWeight: 600,
whiteSpace: 'nowrap',
display: 'inline-flex',
alignItems: 'center',
gap: 4,
}}
>
{badge.label}
</span>
);
return <Badge tone={badge.tone} size="sm">{badge.label}</Badge>;
}

export default function VaultCard({
Expand Down
43 changes: 43 additions & 0 deletions src/components/__tests__/Badge.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
import { Badge } from '../Badge';

describe('Badge', () => {
it.each([
['neutral', 'sm', 'var(--muted)', 'color-mix(in srgb, var(--muted) 10%, transparent)'],
['info', 'md', 'var(--info)', 'color-mix(in srgb, var(--info) 10%, transparent)'],
['success', 'lg', 'var(--success)', 'color-mix(in srgb, var(--success) 10%, transparent)'],
['warning', 'sm', 'var(--warning)', 'color-mix(in srgb, var(--warning) 10%, transparent)'],
['danger', 'md', 'var(--danger)', 'color-mix(in srgb, var(--danger) 10%, transparent)'],
] as const)('renders %s tone with %s size', (tone, size, color, background) => {
render(
<Badge tone={tone} size={size}>
{tone}
</Badge>
);

const badge = screen.getByText(tone);
expect(badge).toHaveStyle({
color,
background,
border: `1px solid ${color}`,
padding: size === 'sm' ? '2px 8px' : size === 'md' ? '2px 10px' : '4px 12px',
fontSize: size === 'sm' ? '11px' : size === 'md' ? '12px' : '14px',
});
});

it('uses an accessible aria-label for numeric badges', () => {
render(<Badge count={7}>7</Badge>);

const badge = screen.getByLabelText('Count: 7');
expect(badge).toHaveTextContent('7');
});

it('renders zero counts without dropping them', () => {
render(<Badge count={0}>0</Badge>);

const badge = screen.getByLabelText('Count: 0');
expect(badge).toHaveTextContent('0');
});
});