# Admin Components Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Add SplitPane and EntityList composites to provide reusable master/detail layout and searchable entity list patterns, replacing ~150 lines of duplicated CSS and structure across admin RBAC tabs. **Architecture:** SplitPane is a layout-only component providing a two-column grid with configurable ratio. EntityList provides a searchable, selectable list with render props for item content. They compose together naturally: EntityList slots into SplitPane's list panel. **Tech Stack:** React, TypeScript, CSS Modules, Vitest, React Testing Library **Spec:** `docs/superpowers/specs/2026-03-24-mock-deviations-design.md` (Sections 2, 2b) --- ## File Map | File | Action | Responsibility | |------|--------|----------------| | `src/design-system/composites/SplitPane/SplitPane.tsx` | Create | Two-column grid layout with list/detail slots and empty state | | `src/design-system/composites/SplitPane/SplitPane.module.css` | Create | Grid layout, scrollable panels, empty state styling | | `src/design-system/composites/SplitPane/SplitPane.test.tsx` | Create | 5 test cases for SplitPane | | `src/design-system/composites/EntityList/EntityList.tsx` | Create | Generic searchable, selectable list with render props | | `src/design-system/composites/EntityList/EntityList.module.css` | Create | Header, scrollable list, item hover/selected states | | `src/design-system/composites/EntityList/EntityList.test.tsx` | Create | 11 test cases for EntityList | | `src/design-system/composites/index.ts` | Modify | Add SplitPane and EntityList exports | --- ### Task 1: SplitPane composite **Files:** - Create: `src/design-system/composites/SplitPane/SplitPane.tsx` - Create: `src/design-system/composites/SplitPane/SplitPane.module.css` - Create: `src/design-system/composites/SplitPane/SplitPane.test.tsx` - [ ] **Step 1: Write SplitPane tests** Create `src/design-system/composites/SplitPane/SplitPane.test.tsx`: ```tsx import { describe, it, expect } from 'vitest' import { render, screen } from '@testing-library/react' import { SplitPane } from './SplitPane' describe('SplitPane', () => { it('renders list and detail content', () => { render( User list} detail={
User detail
} />, ) expect(screen.getByText('User list')).toBeInTheDocument() expect(screen.getByText('User detail')).toBeInTheDocument() }) it('shows default empty message when detail is null', () => { render( User list} detail={null} />, ) expect(screen.getByText('Select an item to view details')).toBeInTheDocument() }) it('shows custom empty message when detail is null', () => { render( User list} detail={null} emptyMessage="Pick a user to see info" />, ) expect(screen.getByText('Pick a user to see info')).toBeInTheDocument() }) it('renders with different ratios', () => { const { container, rerender } = render( List} detail={
Detail
} ratio="1:1" />, ) const pane = container.firstChild as HTMLElement expect(pane.style.getPropertyValue('--split-columns')).toBe('1fr 1fr') rerender( List} detail={
Detail
} ratio="2:3" />, ) expect(pane.style.getPropertyValue('--split-columns')).toBe('2fr 3fr') }) it('accepts className', () => { const { container } = render( List} detail={
Detail
} className="custom" />, ) expect(container.firstChild).toHaveClass('custom') }) }) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx` Expected: FAIL — module not found - [ ] **Step 3: Create SplitPane CSS module** Create `src/design-system/composites/SplitPane/SplitPane.module.css`: CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.splitPane`, `.listPane`, `.detailPane`, `.emptyDetail`), generalized with a CSS custom property for the column ratio. ```css .splitPane { display: grid; grid-template-columns: var(--split-columns, 1fr 2fr); gap: 1px; background: var(--border-subtle); border: 1px solid var(--border-subtle); border-radius: var(--radius-lg); min-height: 0; height: 100%; box-shadow: var(--shadow-card); } .listPane { background: var(--bg-surface); display: flex; flex-direction: column; border-radius: var(--radius-lg) 0 0 var(--radius-lg); overflow-y: auto; } .detailPane { background: var(--bg-raised); overflow-y: auto; padding: 20px; border-radius: 0 var(--radius-lg) var(--radius-lg) 0; } .emptyDetail { display: flex; align-items: center; justify-content: center; height: 100%; color: var(--text-faint); font-size: 13px; font-family: var(--font-body); font-style: italic; } ``` - [ ] **Step 4: Create SplitPane component** Create `src/design-system/composites/SplitPane/SplitPane.tsx`: ```tsx import type { ReactNode } from 'react' import styles from './SplitPane.module.css' interface SplitPaneProps { list: ReactNode detail: ReactNode | null emptyMessage?: string ratio?: '1:1' | '1:2' | '2:3' className?: string } const ratioMap: Record = { '1:1': '1fr 1fr', '1:2': '1fr 2fr', '2:3': '2fr 3fr', } export function SplitPane({ list, detail, emptyMessage = 'Select an item to view details', ratio = '1:2', className, }: SplitPaneProps) { return (
{list}
{detail !== null ? detail : (
{emptyMessage}
)}
) } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npx vitest run src/design-system/composites/SplitPane/SplitPane.test.tsx` Expected: 5 tests PASS - [ ] **Step 6: Commit** ```bash git add src/design-system/composites/SplitPane/SplitPane.tsx \ src/design-system/composites/SplitPane/SplitPane.module.css \ src/design-system/composites/SplitPane/SplitPane.test.tsx git commit -m "feat: add SplitPane composite for master/detail layouts" ``` --- ### Task 2: EntityList composite **Files:** - Create: `src/design-system/composites/EntityList/EntityList.tsx` - Create: `src/design-system/composites/EntityList/EntityList.module.css` - Create: `src/design-system/composites/EntityList/EntityList.test.tsx` - [ ] **Step 1: Write EntityList tests** Create `src/design-system/composites/EntityList/EntityList.test.tsx`: ```tsx 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: 'Alice' }, { id: '2', name: 'Bob' }, { id: '3', name: 'Charlie' }, ] const defaultProps = { items, renderItem: (item: TestItem) => {item.name}, getItemId: (item: TestItem) => item.id, } describe('EntityList', () => { it('renders all items', () => { render() expect(screen.getByText('Alice')).toBeInTheDocument() expect(screen.getByText('Bob')).toBeInTheDocument() expect(screen.getByText('Charlie')).toBeInTheDocument() }) it('calls onSelect when item clicked', async () => { const onSelect = vi.fn() const user = userEvent.setup() render() await user.click(screen.getByText('Bob')) expect(onSelect).toHaveBeenCalledWith('2') }) it('highlights selected item', () => { render() const selectedOption = screen.getByText('Bob').closest('[role="option"]') expect(selectedOption).toHaveAttribute('aria-selected', 'true') expect(selectedOption).toHaveClass(/selected/i) }) it('renders search input when onSearch provided', () => { render() expect(screen.getByPlaceholderText('Search users...')).toBeInTheDocument() }) it('calls onSearch when typing in search', async () => { const onSearch = vi.fn() const user = userEvent.setup() render() await user.type(screen.getByPlaceholderText('Search...'), 'alice') expect(onSearch).toHaveBeenLastCalledWith('alice') }) it('renders add button when onAdd provided', () => { render() expect(screen.getByRole('button', { name: '+ Add user' })).toBeInTheDocument() }) it('calls onAdd when add button clicked', async () => { const onAdd = vi.fn() const user = userEvent.setup() render() await user.click(screen.getByRole('button', { name: '+ Add user' })) expect(onAdd).toHaveBeenCalledOnce() }) it('hides header when no search or add', () => { const { container } = render() // No header element should be rendered (no search input, no add button) expect(screen.queryByPlaceholderText('Search...')).not.toBeInTheDocument() expect(container.querySelector('[class*="listHeader"]')).not.toBeInTheDocument() }) it('shows empty message when items is empty', () => { render( } getItemId={() => ''} />, ) expect(screen.getByText('No items found')).toBeInTheDocument() }) it('shows custom empty message', () => { render( } getItemId={() => ''} emptyMessage="No users match your search" />, ) expect(screen.getByText('No users match your search')).toBeInTheDocument() }) it('accepts className', () => { const { container } = render() expect(container.firstChild).toHaveClass('custom') }) }) ``` - [ ] **Step 2: Run tests to verify they fail** Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx` Expected: FAIL — module not found - [ ] **Step 3: Create EntityList CSS module** Create `src/design-system/composites/EntityList/EntityList.module.css`: CSS extracted from `src/pages/Admin/UserManagement/UserManagement.module.css` (`.listHeader`, `.listHeaderSearch`, `.entityList`, `.entityItem`, `.entityItemSelected`), generalized for reuse. ```css .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); } ``` - [ ] **Step 4: Create EntityList component** Create `src/design-system/composites/EntityList/EntityList.tsx`: The component uses `role="listbox"` / `role="option"` for accessibility, matching the pattern in `UsersTab.tsx`. It delegates search input and add button to the existing `Input` and `Button` primitives. ```tsx 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}
)}
) } ``` - [ ] **Step 5: Run tests to verify they pass** Run: `npx vitest run src/design-system/composites/EntityList/EntityList.test.tsx` Expected: 11 tests PASS - [ ] **Step 6: Commit** ```bash git add src/design-system/composites/EntityList/EntityList.tsx \ src/design-system/composites/EntityList/EntityList.module.css \ src/design-system/composites/EntityList/EntityList.test.tsx git commit -m "feat: add EntityList composite for searchable, selectable lists" ``` --- ### Task 3: Barrel exports & full test suite **Files:** - Modify: `src/design-system/composites/index.ts` - [ ] **Step 1: Add exports to barrel** Add these lines to `src/design-system/composites/index.ts` in alphabetical position. After the `DetailPanel` export (line 13), add: ```ts export { EntityList } from './EntityList/EntityList' ``` After the `LineChart` export (line 19), before `LoginDialog`, add: ```ts // (no change needed here — LoginDialog is already present) ``` After the `ShortcutsBar` export (line 33), before `SegmentedTabs`, add: ```ts export { SplitPane } from './SplitPane/SplitPane' ``` The resulting new lines in `index.ts` (in their alphabetical positions): ```ts export { EntityList } from './EntityList/EntityList' ``` ```ts export { SplitPane } from './SplitPane/SplitPane' ``` - [ ] **Step 2: Run the full component test suite** Run: `npx vitest run src/design-system/composites/SplitPane/ src/design-system/composites/EntityList/` Expected: All 16 tests PASS (5 SplitPane + 11 EntityList) - [ ] **Step 3: Run the full project test suite to check for regressions** Run: `npx vitest run` Expected: All tests PASS - [ ] **Step 4: Commit** ```bash git add src/design-system/composites/index.ts git commit -m "feat: export SplitPane and EntityList from composites barrel" ```