feat: Breadcrumb and Tabs composites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-18 09:42:03 +01:00
parent d2df6bcd3e
commit e9fc8c24e8
5 changed files with 191 additions and 0 deletions

View File

@@ -0,0 +1,35 @@
.list {
display: flex;
align-items: center;
gap: 6px;
list-style: none;
font-size: 13px;
color: var(--text-muted);
flex-wrap: wrap;
}
.item {
display: flex;
align-items: center;
gap: 6px;
}
.sep {
color: var(--text-faint);
font-size: 11px;
}
.link {
color: var(--text-muted);
text-decoration: none;
transition: color 0.12s;
}
.link:hover {
color: var(--text-secondary);
}
.active {
color: var(--text-primary);
font-weight: 600;
}

View File

@@ -0,0 +1,37 @@
import styles from './Breadcrumb.module.css'
interface BreadcrumbItem {
label: string
href?: string
}
interface BreadcrumbProps {
items: BreadcrumbItem[]
className?: string
}
export function Breadcrumb({ items, className }: BreadcrumbProps) {
return (
<nav aria-label="Breadcrumb" className={className}>
<ol className={styles.list}>
{items.map((item, index) => {
const isLast = index === items.length - 1
return (
<li key={index} className={styles.item}>
{index > 0 && <span className={styles.sep}>/</span>}
{isLast ? (
<span className={styles.active}>{item.label}</span>
) : item.href ? (
<a href={item.href} className={styles.link}>
{item.label}
</a>
) : (
<span className={styles.link}>{item.label}</span>
)}
</li>
)
})}
</ol>
</nav>
)
}

View File

@@ -0,0 +1,52 @@
.bar {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-subtle);
gap: 0;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 9px 16px;
font-size: 13px;
font-weight: 500;
font-family: var(--font-body);
color: var(--text-muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
white-space: nowrap;
}
.tab:hover {
color: var(--text-secondary);
}
.tab.active {
color: var(--amber);
border-bottom-color: var(--amber);
}
.label {
/* no special styles needed */
}
.count {
font-family: var(--font-mono);
font-size: 10px;
background: var(--bg-inset);
color: var(--text-muted);
padding: 1px 5px;
border-radius: 10px;
line-height: 1.4;
}
.tab.active .count {
background: var(--amber-bg);
color: var(--amber);
}

View File

@@ -0,0 +1,31 @@
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Tabs } from './Tabs'
describe('Tabs', () => {
const tabs = [
{ label: 'All', count: 14, value: 'all' },
{ label: 'Executions', count: 8, value: 'executions' },
{ label: 'Routes', count: 3, value: 'routes' },
]
it('renders all tab labels', () => {
render(<Tabs tabs={tabs} active="all" onChange={() => {}} />)
expect(screen.getByText('All')).toBeInTheDocument()
expect(screen.getByText('Executions')).toBeInTheDocument()
})
it('shows count badges', () => {
render(<Tabs tabs={tabs} active="all" onChange={() => {}} />)
expect(screen.getByText('14')).toBeInTheDocument()
})
it('calls onChange with tab value', async () => {
const onChange = vi.fn()
const user = userEvent.setup()
render(<Tabs tabs={tabs} active="all" onChange={onChange} />)
await user.click(screen.getByText('Routes'))
expect(onChange).toHaveBeenCalledWith('routes')
})
})

View File

@@ -0,0 +1,36 @@
import styles from './Tabs.module.css'
interface TabItem {
label: string
count?: number
value: string
}
interface TabsProps {
tabs: TabItem[]
active: string
onChange: (value: string) => void
className?: string
}
export function Tabs({ tabs, active, onChange, className }: TabsProps) {
return (
<div className={`${styles.bar} ${className ?? ''}`} role="tablist">
{tabs.map((tab) => (
<button
key={tab.value}
role="tab"
aria-selected={tab.value === active}
className={`${styles.tab} ${tab.value === active ? styles.active : ''}`}
onClick={() => onChange(tab.value)}
type="button"
>
<span className={styles.label}>{tab.label}</span>
{tab.count !== undefined && (
<span className={styles.count}>{tab.count}</span>
)}
</button>
))}
</div>
)
}