feat: MenuItem and Dropdown composites

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

View 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;
}

View 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()
})
})

View 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>
)
}

View 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);
}

View 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>
)
}