Add sidebar section groups (PROJECTS, EXPERIMENTS, RESPONSIBILITIES, PROCEDURES)
Each section is collapsible with expand/collapse toggle, shows entity count, and has a + button. Entities are clickable. SidebarSelection type introduced for managing navigation state across filters, section groups, entities, and topics. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -4,11 +4,13 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar__header {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid #2a2a4a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__header h2 {
|
||||
@@ -20,14 +22,97 @@
|
||||
|
||||
.sidebar__nav {
|
||||
padding: 8px 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Section Groups */
|
||||
.sidebar__section {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.sidebar__section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 6px 8px;
|
||||
margin: 0 4px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
color: #888;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sidebar__section-header:hover {
|
||||
background: #2a2a4a;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.sidebar__section-header--active {
|
||||
background: #2a2a4a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.sidebar__collapse-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
cursor: pointer;
|
||||
padding: 0 4px 0 0;
|
||||
line-height: 1;
|
||||
width: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sidebar__section-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.sidebar__section-count {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.sidebar__add-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
padding: 0 2px;
|
||||
line-height: 1;
|
||||
border-radius: 3px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.sidebar__section-header:hover .sidebar__add-btn {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar__add-btn:hover {
|
||||
color: #ffffff;
|
||||
background: #3a3a5a;
|
||||
}
|
||||
|
||||
/* Section items (entity list) */
|
||||
.sidebar__section-items {
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.sidebar__item {
|
||||
padding: 8px 16px;
|
||||
padding: 5px 16px 5px 28px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-size: 13px;
|
||||
border-radius: 4px;
|
||||
margin: 2px 8px;
|
||||
margin: 1px 4px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar__item:hover {
|
||||
|
||||
@@ -1,18 +1,128 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
|
||||
const mockEntries: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/project/build-app.md',
|
||||
filename: 'build-app.md',
|
||||
title: 'Build Laputa App',
|
||||
isA: 'Project',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
fileSize: 1024,
|
||||
},
|
||||
{
|
||||
path: '/vault/responsibility/grow-newsletter.md',
|
||||
filename: 'grow-newsletter.md',
|
||||
title: 'Grow Newsletter',
|
||||
isA: 'Responsibility',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
fileSize: 512,
|
||||
},
|
||||
{
|
||||
path: '/vault/experiment/stock-screener.md',
|
||||
filename: 'stock-screener.md',
|
||||
title: 'Stock Screener',
|
||||
isA: 'Experiment',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
modifiedAt: 1700000000,
|
||||
fileSize: 256,
|
||||
},
|
||||
{
|
||||
path: '/vault/procedure/weekly-essays.md',
|
||||
filename: 'weekly-essays.md',
|
||||
title: 'Write Weekly Essays',
|
||||
isA: 'Procedure',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: 'Active',
|
||||
owner: 'Luca',
|
||||
cadence: 'Weekly',
|
||||
modifiedAt: 1700000000,
|
||||
fileSize: 128,
|
||||
},
|
||||
]
|
||||
|
||||
const defaultSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
|
||||
describe('Sidebar', () => {
|
||||
it('renders the app title', () => {
|
||||
render(<Sidebar />)
|
||||
render(<Sidebar entries={[]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Laputa')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders navigation items', () => {
|
||||
render(<Sidebar />)
|
||||
expect(screen.getByText('All Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Projects')).toBeInTheDocument()
|
||||
expect(screen.getByText('Tasks')).toBeInTheDocument()
|
||||
expect(screen.getByText('People')).toBeInTheDocument()
|
||||
it('renders section group headers', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('PROJECTS')).toBeInTheDocument()
|
||||
expect(screen.getByText('EXPERIMENTS')).toBeInTheDocument()
|
||||
expect(screen.getByText('RESPONSIBILITIES')).toBeInTheDocument()
|
||||
expect(screen.getByText('PROCEDURES')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entity names under their section groups', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
expect(screen.getByText('Grow Newsletter')).toBeInTheDocument()
|
||||
expect(screen.getByText('Stock Screener')).toBeInTheDocument()
|
||||
expect(screen.getByText('Write Weekly Essays')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows correct counts per section', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Each section has exactly 1 item
|
||||
const counts = screen.getAllByText('1')
|
||||
expect(counts.length).toBe(4)
|
||||
})
|
||||
|
||||
it('collapses and expands sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
|
||||
// Collapse PROJECTS section
|
||||
fireEvent.click(screen.getByLabelText('Collapse PROJECTS'))
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
|
||||
// Expand PROJECTS section
|
||||
fireEvent.click(screen.getByLabelText('Expand PROJECTS'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking an entity', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'entity',
|
||||
entry: mockEntries[0],
|
||||
})
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking a section header', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByText('PROJECTS'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'sectionGroup',
|
||||
type: 'Project',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,96 @@
|
||||
import { useState } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import './Sidebar.css'
|
||||
|
||||
export function Sidebar() {
|
||||
interface SidebarProps {
|
||||
entries: VaultEntry[]
|
||||
selection: SidebarSelection
|
||||
onSelect: (selection: SidebarSelection) => void
|
||||
}
|
||||
|
||||
const SECTION_GROUPS = [
|
||||
{ label: 'PROJECTS', type: 'Project' },
|
||||
{ label: 'EXPERIMENTS', type: 'Experiment' },
|
||||
{ label: 'RESPONSIBILITIES', type: 'Responsibility' },
|
||||
{ label: 'PROCEDURES', type: 'Procedure' },
|
||||
] as const
|
||||
|
||||
export function Sidebar({ entries, selection, onSelect }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
|
||||
const toggleSection = (type: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [type]: !prev[type] }))
|
||||
}
|
||||
|
||||
const isActive = (sel: SidebarSelection): boolean => {
|
||||
if (selection.kind !== sel.kind) return false
|
||||
if (sel.kind === 'filter' && selection.kind === 'filter') return sel.filter === selection.filter
|
||||
if (sel.kind === 'sectionGroup' && selection.kind === 'sectionGroup') return sel.type === selection.type
|
||||
if (sel.kind === 'entity' && selection.kind === 'entity') return sel.entry.path === selection.entry.path
|
||||
if (sel.kind === 'topic' && selection.kind === 'topic') return sel.entry.path === selection.entry.path
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar__header">
|
||||
<h2>Laputa</h2>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar__nav">
|
||||
<div className="sidebar__item sidebar__item--active">All Notes</div>
|
||||
<div className="sidebar__item">Projects</div>
|
||||
<div className="sidebar__item">Tasks</div>
|
||||
<div className="sidebar__item">People</div>
|
||||
{SECTION_GROUPS.map(({ label, type }) => {
|
||||
const items = entries.filter((e) => e.isA === type)
|
||||
const isCollapsed = collapsed[type] ?? false
|
||||
|
||||
return (
|
||||
<div key={type} className="sidebar__section">
|
||||
<div
|
||||
className={`sidebar__section-header${
|
||||
isActive({ kind: 'sectionGroup', type }) ? ' sidebar__section-header--active' : ''
|
||||
}`}
|
||||
onClick={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
>
|
||||
<button
|
||||
className="sidebar__collapse-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleSection(type)
|
||||
}}
|
||||
aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}
|
||||
>
|
||||
{isCollapsed ? '▸' : '▾'}
|
||||
</button>
|
||||
<span className="sidebar__section-label">{label}</span>
|
||||
<span className="sidebar__section-count">{items.length}</span>
|
||||
<button
|
||||
className="sidebar__add-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// TODO: Wire up create new entity
|
||||
}}
|
||||
aria-label={`Add ${type}`}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
{!isCollapsed && (
|
||||
<div className="sidebar__section-items">
|
||||
{items.map((entry) => (
|
||||
<div
|
||||
key={entry.path}
|
||||
className={`sidebar__item${
|
||||
isActive({ kind: 'entity', entry }) ? ' sidebar__item--active' : ''
|
||||
}`}
|
||||
onClick={() => onSelect({ kind: 'entity', entry })}
|
||||
>
|
||||
{entry.title}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
</aside>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user