feat: Breadcrumb and Tabs composites
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
37
src/design-system/composites/Breadcrumb/Breadcrumb.tsx
Normal file
37
src/design-system/composites/Breadcrumb/Breadcrumb.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
52
src/design-system/composites/Tabs/Tabs.module.css
Normal file
52
src/design-system/composites/Tabs/Tabs.module.css
Normal 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);
|
||||
}
|
||||
31
src/design-system/composites/Tabs/Tabs.test.tsx
Normal file
31
src/design-system/composites/Tabs/Tabs.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
36
src/design-system/composites/Tabs/Tabs.tsx
Normal file
36
src/design-system/composites/Tabs/Tabs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user