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