feat: add EntityList composite for searchable, selectable item lists

Generic list component with render props for item content, search input,
add button, selection highlighting, and keyboard navigation support.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-24 12:23:56 +01:00
parent 5fe7752b46
commit 4abf80144e
4 changed files with 314 additions and 0 deletions

View File

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

View File

@@ -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(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
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(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
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(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
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(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
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(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
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(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
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(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
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(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
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(
<EntityList
items={[]}
renderItem={(item: TestItem) => <span>{item.name}</span>}
getItemId={(item: TestItem) => item.id}
/>
)
expect(screen.getByText('No items found')).toBeInTheDocument()
})
it('shows custom empty message', () => {
render(
<EntityList
items={[]}
renderItem={(item: TestItem) => <span>{item.name}</span>}
getItemId={(item: TestItem) => item.id}
emptyMessage="Nothing here"
/>
)
expect(screen.getByText('Nothing here')).toBeInTheDocument()
})
it('accepts className', () => {
const { container } = render(
<EntityList
items={items}
renderItem={(item) => <span>{item.name}</span>}
getItemId={(item) => item.id}
className="custom-class"
/>
)
expect(container.firstChild).toHaveClass('custom-class')
})
})

View File

@@ -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<T> {
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<T>({
items,
renderItem,
getItemId,
selectedId,
onSelect,
searchPlaceholder = 'Search...',
onSearch,
addLabel,
onAdd,
emptyMessage = 'No items found',
className,
}: EntityListProps<T>) {
const [searchValue, setSearchValue] = useState('')
const showHeader = !!onSearch || !!onAdd
function handleSearchChange(e: React.ChangeEvent<HTMLInputElement>) {
const value = e.target.value
setSearchValue(value)
onSearch?.(value)
}
function handleSearchClear() {
setSearchValue('')
onSearch?.('')
}
return (
<div className={`${styles.entityListRoot} ${className ?? ''}`}>
{showHeader && (
<div className={styles.listHeader}>
{onSearch && (
<Input
placeholder={searchPlaceholder}
value={searchValue}
onChange={handleSearchChange}
onClear={handleSearchClear}
className={styles.listHeaderSearch}
/>
)}
{onAdd && addLabel && (
<Button size="sm" variant="secondary" onClick={onAdd}>
{addLabel}
</Button>
)}
</div>
)}
<div className={styles.list} role="listbox">
{items.map((item) => {
const id = getItemId(item)
const isSelected = id === selectedId
return (
<div
key={id}
className={`${styles.entityItem} ${isSelected ? styles.entityItemSelected : ''}`}
onClick={() => onSelect?.(id)}
role="option"
tabIndex={0}
aria-selected={isSelected}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
onSelect?.(id)
}
}}
>
{renderItem(item, isSelected)}
</div>
)
})}
{items.length === 0 && (
<div className={styles.emptyMessage}>{emptyMessage}</div>
)}
</div>
</div>
)
}

View File

@@ -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'