feat: MenuItem and Dropdown composites
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
71
src/design-system/composites/Dropdown/Dropdown.module.css
Normal file
71
src/design-system/composites/Dropdown/Dropdown.module.css
Normal file
@@ -0,0 +1,71 @@
|
||||
.container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: inline-flex;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
right: 0;
|
||||
min-width: 160px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-md);
|
||||
z-index: 200;
|
||||
list-style: none;
|
||||
padding: 4px 0;
|
||||
animation: fadeIn 0.1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.menuItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 7px 12px;
|
||||
font-size: 13px;
|
||||
font-family: var(--font-body);
|
||||
color: var(--text-primary);
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.menuItem:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.menuItem.disabled {
|
||||
color: var(--text-muted);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.menuItem.disabled:hover {
|
||||
background: none;
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--border-subtle);
|
||||
margin: 4px 0;
|
||||
}
|
||||
39
src/design-system/composites/Dropdown/Dropdown.test.tsx
Normal file
39
src/design-system/composites/Dropdown/Dropdown.test.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { Dropdown } from './Dropdown'
|
||||
|
||||
const items = [
|
||||
{ label: 'Edit', onClick: vi.fn() },
|
||||
{ label: 'Delete', onClick: vi.fn() },
|
||||
]
|
||||
|
||||
describe('Dropdown', () => {
|
||||
it('does not show menu initially', () => {
|
||||
render(<Dropdown trigger={<button>Actions</button>} items={items} />)
|
||||
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows menu on trigger click', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Dropdown trigger={<button>Actions</button>} items={items} />)
|
||||
await user.click(screen.getByText('Actions'))
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('closes on Esc', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Dropdown trigger={<button>Actions</button>} items={items} />)
|
||||
await user.click(screen.getByText('Actions'))
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByText('Edit')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls item onClick when clicked', async () => {
|
||||
const user = userEvent.setup()
|
||||
render(<Dropdown trigger={<button>Actions</button>} items={items} />)
|
||||
await user.click(screen.getByText('Actions'))
|
||||
await user.click(screen.getByText('Edit'))
|
||||
expect(items[0].onClick).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
82
src/design-system/composites/Dropdown/Dropdown.tsx
Normal file
82
src/design-system/composites/Dropdown/Dropdown.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { useState, useEffect, useRef, type ReactNode } from 'react'
|
||||
import styles from './Dropdown.module.css'
|
||||
|
||||
export interface DropdownItem {
|
||||
label: string
|
||||
icon?: ReactNode
|
||||
onClick?: () => void
|
||||
divider?: boolean
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
interface DropdownProps {
|
||||
trigger: ReactNode
|
||||
items: DropdownItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Dropdown({ trigger, items, className }: DropdownProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Close on outside click
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleClick(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false)
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
// Close on Esc
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
function handleKey(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => document.removeEventListener('keydown', handleKey)
|
||||
}, [open])
|
||||
|
||||
function handleItemClick(item: DropdownItem) {
|
||||
if (item.disabled) return
|
||||
item.onClick?.()
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className={`${styles.container} ${className ?? ''}`}>
|
||||
<div
|
||||
className={styles.trigger}
|
||||
onClick={() => setOpen((prev) => !prev)}
|
||||
>
|
||||
{trigger}
|
||||
</div>
|
||||
{open && (
|
||||
<ul className={styles.menu} role="menu">
|
||||
{items.map((item, index) => {
|
||||
if (item.divider) {
|
||||
return <li key={index} className={styles.divider} role="separator" />
|
||||
}
|
||||
return (
|
||||
<li key={index} role="menuitem">
|
||||
<button
|
||||
className={`${styles.menuItem} ${item.disabled ? styles.disabled : ''}`}
|
||||
onClick={() => handleItemClick(item)}
|
||||
disabled={item.disabled}
|
||||
type="button"
|
||||
>
|
||||
{item.icon && <span className={styles.icon}>{item.icon}</span>}
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
73
src/design-system/composites/MenuItem/MenuItem.module.css
Normal file
73
src/design-system/composites/MenuItem/MenuItem.module.css
Normal file
@@ -0,0 +1,73 @@
|
||||
.item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--sidebar-text);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: all 0.12s;
|
||||
text-decoration: none;
|
||||
border-left: 3px solid transparent;
|
||||
margin-bottom: 1px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.item:hover {
|
||||
background: var(--sidebar-hover);
|
||||
color: #E8DFD4;
|
||||
}
|
||||
|
||||
.item.active {
|
||||
background: var(--sidebar-active);
|
||||
color: var(--amber-light);
|
||||
border-left-color: var(--amber);
|
||||
}
|
||||
|
||||
.health {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
color: var(--sidebar-muted);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item.active .meta {
|
||||
color: rgba(240, 217, 168, 0.6);
|
||||
}
|
||||
|
||||
.count {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 11px;
|
||||
color: var(--sidebar-muted);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
padding: 1px 6px;
|
||||
border-radius: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item.active .count {
|
||||
background: rgba(198, 130, 14, 0.2);
|
||||
color: var(--amber-light);
|
||||
}
|
||||
54
src/design-system/composites/MenuItem/MenuItem.tsx
Normal file
54
src/design-system/composites/MenuItem/MenuItem.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import styles from './MenuItem.module.css'
|
||||
import { StatusDot } from '../../primitives/StatusDot/StatusDot'
|
||||
|
||||
type StatusDotVariant = 'live' | 'stale' | 'dead' | 'success' | 'warning' | 'error' | 'running'
|
||||
|
||||
interface MenuItemProps {
|
||||
label: string
|
||||
meta?: string
|
||||
count?: number
|
||||
health?: StatusDotVariant
|
||||
active?: boolean
|
||||
indent?: number
|
||||
onClick?: () => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function MenuItem({
|
||||
label,
|
||||
meta,
|
||||
count,
|
||||
health,
|
||||
active = false,
|
||||
indent = 0,
|
||||
onClick,
|
||||
className,
|
||||
}: MenuItemProps) {
|
||||
const classes = [
|
||||
styles.item,
|
||||
active ? styles.active : '',
|
||||
className ?? '',
|
||||
].filter(Boolean).join(' ')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classes}
|
||||
style={indent ? { paddingLeft: `${12 + indent * 16}px` } : undefined}
|
||||
onClick={onClick}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') onClick?.()
|
||||
}}
|
||||
>
|
||||
{health && <StatusDot variant={health} className={styles.health} />}
|
||||
<div className={styles.info}>
|
||||
<span className={styles.name}>{label}</span>
|
||||
{meta && <span className={styles.meta}>{meta}</span>}
|
||||
</div>
|
||||
{count !== undefined && (
|
||||
<span className={styles.count}>{count}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user