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:
@@ -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);
|
||||
}
|
||||
167
src/design-system/composites/EntityList/EntityList.test.tsx
Normal file
167
src/design-system/composites/EntityList/EntityList.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
97
src/design-system/composites/EntityList/EntityList.tsx
Normal file
97
src/design-system/composites/EntityList/EntityList.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user