feat: Badge, Avatar, Tag primitives with hashColor integration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 09:30:59 +01:00
parent e37a4d323c
commit 36d6539775
7 changed files with 242 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
.avatar {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
font-weight: 600;
border: 1px solid;
flex-shrink: 0;
}
.sm { width: 24px; height: 24px; font-size: 9px; }
.md { width: 28px; height: 28px; font-size: 11px; }
.lg { width: 40px; height: 40px; font-size: 14px; }

View File

@@ -0,0 +1,29 @@
import styles from './Avatar.module.css'
import { hashColor } from '../../utils/hashColor'
import { useTheme } from '../../providers/ThemeProvider'
interface AvatarProps {
name: string
size?: 'sm' | 'md' | 'lg'
className?: string
}
function getInitials(name: string): string {
const parts = name.split(/[\s-]+/).filter(Boolean)
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase()
return name.slice(0, 2).toUpperCase()
}
export function Avatar({ name, size = 'md', className }: AvatarProps) {
const { theme } = useTheme()
const colors = hashColor(name, theme)
return (
<span
className={`${styles.avatar} ${styles[size]} ${className ?? ''}`}
style={{ backgroundColor: colors.bg, color: colors.text, borderColor: colors.border }}
aria-label={name}
>
{getInitials(name)}
</span>
)
}

View File

@@ -0,0 +1,43 @@
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 1px 8px;
border-radius: 10px;
font-family: var(--font-mono);
font-size: 10px;
font-weight: 600;
white-space: nowrap;
border: 1px solid transparent;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.filled { /* default — inline styles from hashColor or semantic class */ }
.outlined {
background: transparent !important;
}
.dashed {
background: transparent !important;
border-style: dashed;
}
.primary { background: var(--amber-bg); color: var(--amber-deep); border-color: var(--amber-light); }
.success { background: var(--success-bg); color: var(--success); border-color: var(--success-border); }
.warning { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); }
.error { background: var(--error-bg); color: var(--error); border-color: var(--error-border); }
.running { background: var(--running-bg); color: var(--running); border-color: var(--running-border); }
.remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 12px;
line-height: 1;
opacity: 0.5;
padding: 0;
}
.remove:hover { opacity: 1; }

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from 'vitest'
import { render, screen } from '@testing-library/react'
import { ThemeProvider } from '../../providers/ThemeProvider'
import { Badge } from './Badge'
describe('Badge', () => {
it('renders label text', () => {
render(
<ThemeProvider>
<Badge label="VIEWER" />
</ThemeProvider>,
)
expect(screen.getByText('VIEWER')).toBeInTheDocument()
})
it('applies inline hash color when color is auto', () => {
const { container } = render(
<ThemeProvider>
<Badge label="VIEWER" color="auto" />
</ThemeProvider>,
)
const badge = container.firstChild as HTMLElement
expect(badge.style.backgroundColor).toBeTruthy()
})
it('applies semantic color class when specified', () => {
const { container } = render(
<ThemeProvider>
<Badge label="Failed" color="error" />
</ThemeProvider>,
)
expect(container.firstChild).toHaveClass('error')
})
it('applies dashed variant class', () => {
const { container } = render(
<ThemeProvider>
<Badge label="inherited" variant="dashed" />
</ThemeProvider>,
)
expect(container.firstChild).toHaveClass('dashed')
})
})

View File

@@ -0,0 +1,52 @@
import styles from './Badge.module.css'
import { hashColor } from '../../utils/hashColor'
import { useTheme } from '../../providers/ThemeProvider'
type BadgeColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'
type BadgeVariant = 'filled' | 'outlined' | 'dashed'
interface BadgeProps {
label: string
variant?: BadgeVariant
color?: BadgeColor
onRemove?: () => void
className?: string
}
export function Badge({
label,
variant = 'filled',
color = 'auto',
onRemove,
className,
}: BadgeProps) {
const { theme } = useTheme()
const isAuto = color === 'auto'
const hashColors = isAuto ? hashColor(label, theme) : null
const inlineStyle = isAuto
? {
backgroundColor: variant === 'filled' ? hashColors!.bg : 'transparent',
color: hashColors!.text,
borderColor: hashColors!.border,
}
: undefined
const classes = [
styles.badge,
styles[variant],
!isAuto ? styles[color] : '',
className ?? '',
].filter(Boolean).join(' ')
return (
<span className={classes} style={inlineStyle}>
{label}
{onRemove && (
<button className={styles.remove} onClick={onRemove} aria-label={`Remove ${label}`}>
&times;
</button>
)}
</span>
)
}

View File

@@ -0,0 +1,28 @@
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px;
border: 1px solid;
border-radius: 20px;
font-size: 11px;
font-family: var(--font-mono);
}
.primary { background: var(--amber-bg); color: var(--amber-deep); border-color: var(--amber-light); }
.success { background: var(--success-bg); color: var(--success); border-color: var(--success-border); }
.warning { background: var(--warning-bg); color: var(--warning); border-color: var(--warning-border); }
.error { background: var(--error-bg); color: var(--error); border-color: var(--error-border); }
.running { background: var(--running-bg); color: var(--running); border-color: var(--running-border); }
.remove {
cursor: pointer;
opacity: 0.5;
font-size: 13px;
line-height: 1;
background: none;
border: none;
color: inherit;
padding: 0;
}
.remove:hover { opacity: 1; }

View File

@@ -0,0 +1,35 @@
import styles from './Tag.module.css'
import { hashColor } from '../../utils/hashColor'
import { useTheme } from '../../providers/ThemeProvider'
type TagColor = 'primary' | 'success' | 'warning' | 'error' | 'running' | 'auto'
interface TagProps {
label: string
onRemove?: () => void
color?: TagColor
className?: string
}
export function Tag({ label, onRemove, color = 'auto', className }: TagProps) {
const { theme } = useTheme()
const isAuto = color === 'auto'
const hashColors = isAuto ? hashColor(label, theme) : null
const inlineStyle = isAuto
? { backgroundColor: hashColors!.bg, color: hashColors!.text, borderColor: hashColors!.border }
: undefined
const classes = [styles.tag, !isAuto ? styles[color] : '', className ?? ''].filter(Boolean).join(' ')
return (
<span className={classes} style={inlineStyle}>
{label}
{onRemove && (
<button className={styles.remove} onClick={onRemove} aria-label={`Remove ${label}`}>
&times;
</button>
)}
</span>
)
}