From 388c1092729257668284483ef692d62d367dfb94 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Wed, 18 Mar 2026 09:42:09 +0100 Subject: [PATCH] feat: MenuItem and Dropdown composites Co-Authored-By: Claude Opus 4.6 (1M context) --- .../composites/Dropdown/Dropdown.module.css | 71 ++++++++++++++++ .../composites/Dropdown/Dropdown.test.tsx | 39 +++++++++ .../composites/Dropdown/Dropdown.tsx | 82 +++++++++++++++++++ .../composites/MenuItem/MenuItem.module.css | 73 +++++++++++++++++ .../composites/MenuItem/MenuItem.tsx | 54 ++++++++++++ 5 files changed, 319 insertions(+) create mode 100644 src/design-system/composites/Dropdown/Dropdown.module.css create mode 100644 src/design-system/composites/Dropdown/Dropdown.test.tsx create mode 100644 src/design-system/composites/Dropdown/Dropdown.tsx create mode 100644 src/design-system/composites/MenuItem/MenuItem.module.css create mode 100644 src/design-system/composites/MenuItem/MenuItem.tsx diff --git a/src/design-system/composites/Dropdown/Dropdown.module.css b/src/design-system/composites/Dropdown/Dropdown.module.css new file mode 100644 index 0000000..e7f0d38 --- /dev/null +++ b/src/design-system/composites/Dropdown/Dropdown.module.css @@ -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; +} diff --git a/src/design-system/composites/Dropdown/Dropdown.test.tsx b/src/design-system/composites/Dropdown/Dropdown.test.tsx new file mode 100644 index 0000000..a261472 --- /dev/null +++ b/src/design-system/composites/Dropdown/Dropdown.test.tsx @@ -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(Actions} items={items} />) + expect(screen.queryByText('Edit')).not.toBeInTheDocument() + }) + + it('shows menu on trigger click', async () => { + const user = userEvent.setup() + render(Actions} items={items} />) + await user.click(screen.getByText('Actions')) + expect(screen.getByText('Edit')).toBeInTheDocument() + }) + + it('closes on Esc', async () => { + const user = userEvent.setup() + render(Actions} 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(Actions} items={items} />) + await user.click(screen.getByText('Actions')) + await user.click(screen.getByText('Edit')) + expect(items[0].onClick).toHaveBeenCalled() + }) +}) diff --git a/src/design-system/composites/Dropdown/Dropdown.tsx b/src/design-system/composites/Dropdown/Dropdown.tsx new file mode 100644 index 0000000..25b26b0 --- /dev/null +++ b/src/design-system/composites/Dropdown/Dropdown.tsx @@ -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(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 ( +
+
setOpen((prev) => !prev)} + > + {trigger} +
+ {open && ( +
    + {items.map((item, index) => { + if (item.divider) { + return
  • + } + return ( +
  • + +
  • + ) + })} +
+ )} +
+ ) +} diff --git a/src/design-system/composites/MenuItem/MenuItem.module.css b/src/design-system/composites/MenuItem/MenuItem.module.css new file mode 100644 index 0000000..5ecae68 --- /dev/null +++ b/src/design-system/composites/MenuItem/MenuItem.module.css @@ -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); +} diff --git a/src/design-system/composites/MenuItem/MenuItem.tsx b/src/design-system/composites/MenuItem/MenuItem.tsx new file mode 100644 index 0000000..41bf843 --- /dev/null +++ b/src/design-system/composites/MenuItem/MenuItem.tsx @@ -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 ( +
{ + if (e.key === 'Enter' || e.key === ' ') onClick?.() + }} + > + {health && } +
+ {label} + {meta && {meta}} +
+ {count !== undefined && ( + {count} + )} +
+ ) +}