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