diff --git a/src/design-system/composites/EntityList/EntityList.module.css b/src/design-system/composites/EntityList/EntityList.module.css new file mode 100644 index 0000000..a537409 --- /dev/null +++ b/src/design-system/composites/EntityList/EntityList.module.css @@ -0,0 +1,49 @@ +.entityListRoot { + display: flex; + flex-direction: column; + height: 100%; +} + +.listHeader { + display: flex; + align-items: center; + gap: 8px; + padding: 12px; + border-bottom: 1px solid var(--border-subtle); +} + +.listHeaderSearch { + flex: 1; +} + +.list { + flex: 1; + overflow-y: auto; +} + +.entityItem { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + cursor: pointer; + transition: background 0.1s; + border-bottom: 1px solid var(--border-subtle); +} + +.entityItem:hover { + background: var(--bg-hover); +} + +.entityItemSelected { + background: var(--amber-bg); + border-left: 3px solid var(--amber); +} + +.emptyMessage { + padding: 32px; + text-align: center; + color: var(--text-faint); + font-size: 12px; + font-family: var(--font-body); +} diff --git a/src/design-system/composites/EntityList/EntityList.test.tsx b/src/design-system/composites/EntityList/EntityList.test.tsx new file mode 100644 index 0000000..2696da4 --- /dev/null +++ b/src/design-system/composites/EntityList/EntityList.test.tsx @@ -0,0 +1,167 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { EntityList } from './EntityList' + +interface TestItem { + id: string + name: string +} + +const items: TestItem[] = [ + { id: '1', name: 'Alpha' }, + { id: '2', name: 'Beta' }, + { id: '3', name: 'Gamma' }, +] + +describe('EntityList', () => { + it('renders all items', () => { + render( + {item.name}} + getItemId={(item) => item.id} + /> + ) + expect(screen.getByText('Alpha')).toBeInTheDocument() + expect(screen.getByText('Beta')).toBeInTheDocument() + expect(screen.getByText('Gamma')).toBeInTheDocument() + }) + + it('calls onSelect when item clicked', async () => { + const onSelect = vi.fn() + const user = userEvent.setup() + render( + {item.name}} + getItemId={(item) => item.id} + onSelect={onSelect} + /> + ) + await user.click(screen.getByText('Beta')) + expect(onSelect).toHaveBeenCalledWith('2') + }) + + it('highlights selected item (aria-selected="true" and has selected class)', () => { + render( + {item.name}} + getItemId={(item) => item.id} + selectedId="2" + /> + ) + const selectedOption = screen.getByText('Beta').closest('[role="option"]') + expect(selectedOption).toHaveAttribute('aria-selected', 'true') + + const unselectedOption = screen.getByText('Alpha').closest('[role="option"]') + expect(unselectedOption).toHaveAttribute('aria-selected', 'false') + }) + + it('renders search input when onSearch provided', () => { + render( + {item.name}} + getItemId={(item) => item.id} + onSearch={() => {}} + searchPlaceholder="Filter items..." + /> + ) + expect(screen.getByPlaceholderText('Filter items...')).toBeInTheDocument() + }) + + it('calls onSearch when typing in search', async () => { + const onSearch = vi.fn() + const user = userEvent.setup() + render( + {item.name}} + getItemId={(item) => item.id} + onSearch={onSearch} + /> + ) + const input = screen.getByPlaceholderText('Search...') + await user.type(input, 'test') + expect(onSearch).toHaveBeenLastCalledWith('test') + }) + + it('renders add button when onAdd provided', () => { + render( + {item.name}} + getItemId={(item) => item.id} + onAdd={() => {}} + addLabel="Add Item" + /> + ) + expect(screen.getByText('Add Item')).toBeInTheDocument() + }) + + it('calls onAdd when add button clicked', async () => { + const onAdd = vi.fn() + const user = userEvent.setup() + render( + {item.name}} + getItemId={(item) => item.id} + onAdd={onAdd} + addLabel="Add Item" + /> + ) + await user.click(screen.getByText('Add Item')) + expect(onAdd).toHaveBeenCalledOnce() + }) + + it('hides header when no search or add', () => { + const { container } = render( + {item.name}} + getItemId={(item) => item.id} + /> + ) + // No input or button should be present in the header area + expect(container.querySelector('input')).toBeNull() + expect(container.querySelector('button')).toBeNull() + }) + + it('shows empty message when items is empty', () => { + render( + {item.name}} + getItemId={(item: TestItem) => item.id} + /> + ) + expect(screen.getByText('No items found')).toBeInTheDocument() + }) + + it('shows custom empty message', () => { + render( + {item.name}} + getItemId={(item: TestItem) => item.id} + emptyMessage="Nothing here" + /> + ) + expect(screen.getByText('Nothing here')).toBeInTheDocument() + }) + + it('accepts className', () => { + const { container } = render( + {item.name}} + getItemId={(item) => item.id} + className="custom-class" + /> + ) + expect(container.firstChild).toHaveClass('custom-class') + }) +}) diff --git a/src/design-system/composites/EntityList/EntityList.tsx b/src/design-system/composites/EntityList/EntityList.tsx new file mode 100644 index 0000000..7f07ebe --- /dev/null +++ b/src/design-system/composites/EntityList/EntityList.tsx @@ -0,0 +1,97 @@ +import { useState, type ReactNode } from 'react' +import { Input } from '../../primitives/Input/Input' +import { Button } from '../../primitives/Button/Button' +import styles from './EntityList.module.css' + +interface EntityListProps { + items: T[] + renderItem: (item: T, isSelected: boolean) => ReactNode + getItemId: (item: T) => string + selectedId?: string + onSelect?: (id: string) => void + searchPlaceholder?: string + onSearch?: (query: string) => void + addLabel?: string + onAdd?: () => void + emptyMessage?: string + className?: string +} + +export function EntityList({ + items, + renderItem, + getItemId, + selectedId, + onSelect, + searchPlaceholder = 'Search...', + onSearch, + addLabel, + onAdd, + emptyMessage = 'No items found', + className, +}: EntityListProps) { + const [searchValue, setSearchValue] = useState('') + const showHeader = !!onSearch || !!onAdd + + function handleSearchChange(e: React.ChangeEvent) { + const value = e.target.value + setSearchValue(value) + onSearch?.(value) + } + + function handleSearchClear() { + setSearchValue('') + onSearch?.('') + } + + return ( +
+ {showHeader && ( +
+ {onSearch && ( + + )} + {onAdd && addLabel && ( + + )} +
+ )} + +
+ {items.map((item) => { + const id = getItemId(item) + const isSelected = id === selectedId + return ( +
onSelect?.(id)} + role="option" + tabIndex={0} + aria-selected={isSelected} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect?.(id) + } + }} + > + {renderItem(item, isSelected)} +
+ ) + })} + {items.length === 0 && ( +
{emptyMessage}
+ )} +
+
+ ) +} diff --git a/src/design-system/composites/index.ts b/src/design-system/composites/index.ts index 08890f2..5e457a1 100644 --- a/src/design-system/composites/index.ts +++ b/src/design-system/composites/index.ts @@ -11,6 +11,7 @@ export type { ConfirmDialogProps } from './ConfirmDialog/ConfirmDialog' export { DataTable } from './DataTable/DataTable' export type { Column, DataTableProps } from './DataTable/types' export { DetailPanel } from './DetailPanel/DetailPanel' +export { EntityList } from './EntityList/EntityList' export { Dropdown } from './Dropdown/Dropdown' export { EventFeed } from './EventFeed/EventFeed' export { GroupCard } from './GroupCard/GroupCard'