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 { DataTable } from './DataTable/DataTable'
|
||||||
export type { Column, DataTableProps } from './DataTable/types'
|
export type { Column, DataTableProps } from './DataTable/types'
|
||||||
export { DetailPanel } from './DetailPanel/DetailPanel'
|
export { DetailPanel } from './DetailPanel/DetailPanel'
|
||||||
|
export { EntityList } from './EntityList/EntityList'
|
||||||
export { Dropdown } from './Dropdown/Dropdown'
|
export { Dropdown } from './Dropdown/Dropdown'
|
||||||
export { EventFeed } from './EventFeed/EventFeed'
|
export { EventFeed } from './EventFeed/EventFeed'
|
||||||
export { GroupCard } from './GroupCard/GroupCard'
|
export { GroupCard } from './GroupCard/GroupCard'
|
||||||
|
|||||||
Reference in New Issue
Block a user