refactor: remove expand/collapse from sidebar type sections
Type sections are now flat rows — clicking selects the section and loads the note list. Removed chevrons, inline child lists, and "+" buttons. Added note count chip to each section header. FOLDERS section expand/ collapse is unchanged. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -252,50 +252,18 @@ describe('Sidebar', () => {
|
||||
expect(screen.queryByText('Types')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows entity names under their section groups after expanding', () => {
|
||||
it('does not show inline entity names — sections are flat rows', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Sections start collapsed by default — expand them first
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Responsibilities'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Experiments'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Procedures'))
|
||||
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 People and Events items after expanding', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand People'))
|
||||
fireEvent.click(screen.getByLabelText('Expand Events'))
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument()
|
||||
expect(screen.getByText('Kickoff Meeting')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses and expands sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Start collapsed — items hidden
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
|
||||
// Expand
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
|
||||
// Collapse
|
||||
fireEvent.click(screen.getByLabelText('Collapse Projects'))
|
||||
// Individual entries should NOT appear inline in the sidebar
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Grow Newsletter')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking an entity', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
fireEvent.click(screen.getByText('Build Laputa App'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'entity',
|
||||
entry: mockEntries[0],
|
||||
})
|
||||
it('shows note count chip on type sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Projects section has 1 entry — count chip should be a sibling of the label
|
||||
const projectsHeader = screen.getByText('Projects').closest('[class*="group/section"]')!
|
||||
expect(projectsHeader.textContent).toContain('1')
|
||||
})
|
||||
|
||||
it('calls onSelect when clicking a section header', () => {
|
||||
@@ -308,37 +276,12 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('expands a collapsed section when clicking its header', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
// Sections start collapsed — items hidden
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
// Click the section header text (not the chevron)
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
// Section should now be expanded
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('collapses an expanded+selected section when clicking its header again', () => {
|
||||
const projectSelection: SidebarSelection = { kind: 'sectionGroup', type: 'Project' }
|
||||
render(<Sidebar entries={mockEntries} selection={projectSelection} onSelect={() => {}} />)
|
||||
// First click expands (starts collapsed) and selects
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
// Second click: section is expanded + selected → should collapse
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('selects but keeps expanded an unselected expanded section when clicking its header', () => {
|
||||
it('selects on every click — no expand/collapse toggle', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
// Expand via chevron first
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
// Click the header — section is expanded but not selected → should select and stay expanded
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'sectionGroup', type: 'Project' })
|
||||
expect(screen.getByText('Build Laputa App')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByText('Projects'))
|
||||
expect(onSelect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('calls onSelect with sectionGroup for People', () => {
|
||||
@@ -351,41 +294,15 @@ describe('Sidebar', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('renders Topics section with topic entries after expanding', () => {
|
||||
it('renders Topics section header', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.getByText('Topics')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByLabelText('Expand Topics'))
|
||||
expect(screen.getByText('Software Development')).toBeInTheDocument()
|
||||
expect(screen.getByText('Trading')).toBeInTheDocument()
|
||||
// Topic entries are NOT shown inline
|
||||
expect(screen.queryByText('Software Development')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onSelect with entity kind when clicking a topic', () => {
|
||||
const onSelect = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={onSelect} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Topics'))
|
||||
fireEvent.click(screen.getByText('Software Development'))
|
||||
expect(onSelect).toHaveBeenCalledWith({
|
||||
kind: 'entity',
|
||||
entry: mockEntries[4],
|
||||
})
|
||||
})
|
||||
|
||||
it('renders + buttons for each section group when onCreateType is provided', () => {
|
||||
const onCreateType = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
||||
const createButtons = screen.getAllByTitle(/^New /)
|
||||
expect(createButtons.length).toBe(7) // Projects, Experiments, Responsibilities, Procedures, People, Events, Topics (no Type entries → no Types section)
|
||||
})
|
||||
|
||||
it('calls onCreateType with correct type when + button is clicked', () => {
|
||||
const onCreateType = vi.fn()
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
||||
fireEvent.click(screen.getByTitle('New Project'))
|
||||
expect(onCreateType).toHaveBeenCalledWith('Project')
|
||||
})
|
||||
|
||||
it('does not render + buttons when onCreateType is not provided', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
it('does not render + buttons on type sections', () => {
|
||||
render(<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
|
||||
expect(screen.queryByTitle('New Project')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -517,24 +434,9 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Recipes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows instances of custom types under their section after expanding', () => {
|
||||
it('does not show inline instances — sections are flat rows', () => {
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Recipes'))
|
||||
expect(screen.getByText('Pasta Carbonara')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders + button on custom type sections for creating instances', () => {
|
||||
const onCreateType = vi.fn()
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateType={onCreateType} />)
|
||||
fireEvent.click(screen.getByTitle('New Recipe'))
|
||||
expect(onCreateType).toHaveBeenCalledWith('Recipe')
|
||||
})
|
||||
|
||||
it('calls onCreateNewType when + is clicked on Types section', () => {
|
||||
const onCreateNewType = vi.fn()
|
||||
render(<Sidebar entries={entriesWithCustomTypes} selection={defaultSelection} onSelect={() => {}} onCreateNewType={onCreateNewType} />)
|
||||
fireEvent.click(screen.getByTitle('New Type'))
|
||||
expect(onCreateNewType).toHaveBeenCalled()
|
||||
expect(screen.queryByText('Pasta Carbonara')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not show section for type with zero active entries', () => {
|
||||
@@ -926,11 +828,10 @@ describe('Sidebar', () => {
|
||||
expect(screen.getByText('Notes')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('includes both explicit and untyped notes under Notes section', () => {
|
||||
it('counts both explicit and untyped notes in Notes section chip', () => {
|
||||
render(<Sidebar entries={noteEntries} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Notes'))
|
||||
expect(screen.getByText('Explicit Note')).toBeInTheDocument()
|
||||
expect(screen.getByText('Untyped Note')).toBeInTheDocument()
|
||||
const notesHeader = screen.getByText('Notes').closest('[class*="group/section"]')!
|
||||
expect(notesHeader.textContent).toContain('2')
|
||||
})
|
||||
|
||||
it('shows Notes section for untyped entries even without explicit Note entries', () => {
|
||||
@@ -1007,16 +908,8 @@ describe('Sidebar', () => {
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'inbox' })
|
||||
})
|
||||
|
||||
describe('emoji icon in sidebar section children', () => {
|
||||
it('does not show inline entries — no child items in type sections', () => {
|
||||
const entriesWithEmoji: VaultEntry[] = [
|
||||
{
|
||||
path: '/vault/project.md', filename: 'project.md', title: 'Project', isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null, cadence: null,
|
||||
archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000, createdAt: null,
|
||||
fileSize: 200, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: 'rocket-launch', color: 'purple', order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/project/build-app.md', filename: 'build-app.md', title: 'Build App',
|
||||
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
@@ -1025,30 +918,8 @@ describe('Sidebar', () => {
|
||||
icon: '🚀', color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
},
|
||||
{
|
||||
path: '/vault/project/no-icon.md', filename: 'no-icon.md', title: 'No Icon Project',
|
||||
isA: 'Project', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, trashed: false, trashedAt: null, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 150, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
},
|
||||
]
|
||||
|
||||
it('shows emoji icon before title in expanded section child', () => {
|
||||
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
const buildApp = screen.getByText('Build App')
|
||||
const parent = buildApp.closest('div')!
|
||||
expect(parent.textContent).toBe('🚀Build App')
|
||||
})
|
||||
|
||||
it('does not show emoji for notes without icon', () => {
|
||||
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
|
||||
fireEvent.click(screen.getByLabelText('Expand Projects'))
|
||||
const noIcon = screen.getByText('No Icon Project')
|
||||
const parent = noIcon.closest('div')!
|
||||
expect(parent.textContent).toBe('No Icon Project')
|
||||
})
|
||||
render(<Sidebar entries={entriesWithEmoji} selection={defaultSelection} onSelect={() => {}} />)
|
||||
expect(screen.queryByText('Build App')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -112,24 +112,21 @@ function applyCustomization(
|
||||
|
||||
function SortableSection({ group, sectionProps }: {
|
||||
group: SectionGroup
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'items' | 'isCollapsed' | 'onToggle' | 'isRenaming' | 'renameInitialValue'>
|
||||
& { entries: VaultEntry[]; collapsed: Record<string, boolean>; onToggle: (type: string) => void; renamingType: string | null; renameInitialValue: string }
|
||||
sectionProps: Omit<SectionContentProps, 'group' | 'itemCount' | 'isRenaming' | 'renameInitialValue'>
|
||||
& { entries: VaultEntry[]; renamingType: string | null; renameInitialValue: string }
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: group.type })
|
||||
const items = sectionProps.entries.filter((e) =>
|
||||
const itemCount = sectionProps.entries.filter((e) =>
|
||||
!e.archived && !e.trashed && (group.type === 'Note' ? (e.isA === 'Note' || !e.isA) : e.isA === group.type),
|
||||
)
|
||||
const isCollapsed = sectionProps.collapsed[group.type] ?? true
|
||||
).length
|
||||
const isRenaming = sectionProps.renamingType === group.type
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: isCollapsed ? '0 6px' : '4px 6px' }} {...attributes}>
|
||||
<div ref={setNodeRef} style={{ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, padding: '0 6px' }} {...attributes}>
|
||||
<SectionContent
|
||||
group={group} items={items} isCollapsed={isCollapsed}
|
||||
group={group} itemCount={itemCount}
|
||||
selection={sectionProps.selection} onSelect={sectionProps.onSelect}
|
||||
onSelectNote={sectionProps.onSelectNote} onCreateType={sectionProps.onCreateType}
|
||||
onCreateNewType={sectionProps.onCreateNewType} onContextMenu={sectionProps.onContextMenu}
|
||||
onToggle={() => sectionProps.onToggle(group.type)}
|
||||
onContextMenu={sectionProps.onContextMenu}
|
||||
dragHandleProps={listeners}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={isRenaming ? sectionProps.renameInitialValue : undefined}
|
||||
@@ -205,12 +202,11 @@ function CustomizeOverlay({ target, typeEntryMap, innerRef, onCustomize, onChang
|
||||
// --- Main Sidebar ---
|
||||
|
||||
export const Sidebar = memo(function Sidebar({
|
||||
entries, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
entries, selection, onSelect,
|
||||
onCustomizeType, onUpdateTypeTemplate, onReorderSections, onRenameSection,
|
||||
onToggleTypeVisibility,
|
||||
folders = [], onCreateFolder, inboxCount = 0, onCollapse,
|
||||
}: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
|
||||
const [customizeTarget, setCustomizeTarget] = useState<string | null>(null)
|
||||
const [contextMenuPos, setContextMenuPos] = useState<{ x: number; y: number } | null>(null)
|
||||
const [renamingType, setRenamingType] = useState<string | null>(null)
|
||||
@@ -236,10 +232,6 @@ export const Sidebar = memo(function Sidebar({
|
||||
useOutsideClick(contextMenuRef, !!contextMenuPos, closeContextMenu)
|
||||
useOutsideClick(popoverRef, !!customizeTarget, closeCustomizeTarget)
|
||||
|
||||
const toggleSection = useCallback((type: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [type]: !(prev[type] ?? true) }))
|
||||
}, [])
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
@@ -280,8 +272,8 @@ export const Sidebar = memo(function Sidebar({
|
||||
}, [customizeTarget, onUpdateTypeTemplate])
|
||||
|
||||
const sectionProps = {
|
||||
entries, collapsed, selection, onSelect, onSelectNote, onCreateType, onCreateNewType,
|
||||
onContextMenu: handleContextMenu, onToggle: toggleSection,
|
||||
entries, selection, onSelect,
|
||||
onContextMenu: handleContextMenu,
|
||||
renamingType, renameInitialValue, onRenameSubmit: handleRenameSubmit, onRenameCancel: cancelRename,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { type ComponentType, useState, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
import type { SidebarSelection } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { isEmoji } from '../utils/emoji'
|
||||
import { ChevronRight, ChevronDown, Plus } from 'lucide-react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
import { getTypeColor } from '../utils/typeColors'
|
||||
import { type IconProps } from '@phosphor-icons/react'
|
||||
|
||||
export interface SectionGroup {
|
||||
@@ -77,15 +75,10 @@ export function NavItem({ icon: Icon, label, count, isActive, activeClassName =
|
||||
|
||||
export interface SectionContentProps {
|
||||
group: SectionGroup
|
||||
items: VaultEntry[]
|
||||
isCollapsed: boolean
|
||||
itemCount: number
|
||||
selection: SidebarSelection
|
||||
onSelect: (sel: SidebarSelection) => void
|
||||
onSelectNote?: (entry: VaultEntry) => void
|
||||
onCreateType?: (type: string) => void
|
||||
onCreateNewType?: () => void
|
||||
onContextMenu: (e: React.MouseEvent, type: string) => void
|
||||
onToggle: () => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean
|
||||
renameInitialValue?: string
|
||||
@@ -93,75 +86,28 @@ export interface SectionContentProps {
|
||||
onRenameCancel?: () => void
|
||||
}
|
||||
|
||||
function childSelection(entry: VaultEntry): SidebarSelection {
|
||||
return { kind: 'entity', entry }
|
||||
}
|
||||
|
||||
function resolveCreateHandler(type: string, onCreateType?: (type: string) => void, onCreateNewType?: () => void): (() => void) | undefined {
|
||||
const isType = type === 'Type'
|
||||
if (!onCreateType && !(isType && onCreateNewType)) return undefined
|
||||
return isType ? () => onCreateNewType?.() : () => onCreateType?.(type)
|
||||
}
|
||||
|
||||
export function SectionContent({
|
||||
group, items, isCollapsed, selection, onSelect, onSelectNote,
|
||||
onCreateType, onCreateNewType, onContextMenu, onToggle, dragHandleProps,
|
||||
group, itemCount, selection, onSelect,
|
||||
onContextMenu, dragHandleProps,
|
||||
isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel,
|
||||
}: SectionContentProps) {
|
||||
const { label, type, Icon, customColor } = group
|
||||
const sectionColor = getTypeColor(type, customColor)
|
||||
const sectionLightColor = getTypeLightColor(type, customColor)
|
||||
const onCreate = resolveCreateHandler(type, onCreateType, onCreateNewType)
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
label={label} type={type} Icon={Icon}
|
||||
sectionColor={sectionColor}
|
||||
isCollapsed={isCollapsed}
|
||||
isActive={isSelectionActive(selection, { kind: 'sectionGroup', type })}
|
||||
showCreate={!!onCreate}
|
||||
onSelect={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
onContextMenu={(e) => onContextMenu(e, type)}
|
||||
onToggle={onToggle}
|
||||
onCreate={(e) => { e.stopPropagation(); onCreate?.() }}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={renameInitialValue}
|
||||
onRenameSubmit={onRenameSubmit}
|
||||
onRenameCancel={onRenameCancel}
|
||||
/>
|
||||
{!isCollapsed && items.length > 0 && (
|
||||
<SectionChildList
|
||||
items={items} selection={selection}
|
||||
sectionColor={sectionColor} sectionLightColor={sectionLightColor}
|
||||
onSelect={onSelect} onSelectNote={onSelectNote}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildList({ items, selection, sectionColor, sectionLightColor, onSelect, onSelectNote }: {
|
||||
items: VaultEntry[]; selection: SidebarSelection
|
||||
sectionColor: string; sectionLightColor: string
|
||||
onSelect: (sel: SidebarSelection) => void; onSelectNote?: (entry: VaultEntry) => void
|
||||
}) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
{items.map((entry) => {
|
||||
const sel = childSelection(entry)
|
||||
const active = isSelectionActive(selection, sel)
|
||||
return (
|
||||
<SectionChildItem
|
||||
key={entry.path} title={entry.title} icon={entry.icon} isActive={active}
|
||||
sectionColor={active ? sectionColor : undefined}
|
||||
sectionLightColor={active ? sectionLightColor : undefined}
|
||||
onClick={() => { onSelect(sel); onSelectNote?.(entry) }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<SectionHeader
|
||||
label={label} type={type} Icon={Icon}
|
||||
sectionColor={sectionColor}
|
||||
itemCount={itemCount}
|
||||
isActive={isSelectionActive(selection, { kind: 'sectionGroup', type })}
|
||||
onSelect={() => onSelect({ kind: 'sectionGroup', type })}
|
||||
onContextMenu={(e) => onContextMenu(e, type)}
|
||||
dragHandleProps={dragHandleProps}
|
||||
isRenaming={isRenaming}
|
||||
renameInitialValue={renameInitialValue}
|
||||
onRenameSubmit={onRenameSubmit}
|
||||
onRenameCancel={onRenameCancel}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -195,11 +141,10 @@ function InlineRenameInput({ initialValue, onSubmit, onCancel }: {
|
||||
)
|
||||
}
|
||||
|
||||
function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive, showCreate, onSelect, onContextMenu, onToggle, onCreate, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
function SectionHeader({ label, type, Icon, sectionColor, itemCount, isActive, onSelect, onContextMenu, dragHandleProps, isRenaming, renameInitialValue, onRenameSubmit, onRenameCancel }: {
|
||||
label: string; type: string; Icon: ComponentType<IconProps>
|
||||
sectionColor: string; isCollapsed: boolean; isActive: boolean; showCreate: boolean
|
||||
sectionColor: string; itemCount: number; isActive: boolean
|
||||
onSelect: () => void; onContextMenu: (e: React.MouseEvent) => void
|
||||
onToggle: () => void; onCreate: (e: React.MouseEvent) => void
|
||||
dragHandleProps?: Record<string, unknown>
|
||||
isRenaming?: boolean; renameInitialValue?: string
|
||||
onRenameSubmit?: (value: string) => void; onRenameCancel?: () => void
|
||||
@@ -209,12 +154,8 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
|
||||
className={cn("group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors", isActive ? "bg-secondary" : "hover:bg-accent")}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4 }}
|
||||
{...dragHandleProps}
|
||||
onClick={() => {
|
||||
if (isRenaming) return
|
||||
if (isCollapsed) { onToggle(); onSelect() }
|
||||
else if (isActive) { onToggle() }
|
||||
else { onSelect() }
|
||||
}} onContextMenu={isRenaming ? undefined : onContextMenu}
|
||||
onClick={() => { if (!isRenaming) onSelect() }}
|
||||
onContextMenu={isRenaming ? undefined : onContextMenu}
|
||||
>
|
||||
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
|
||||
<Icon size={16} style={{ color: sectionColor, flexShrink: 0 }} />
|
||||
@@ -229,32 +170,11 @@ function SectionHeader({ label, type, Icon, sectionColor, isCollapsed, isActive,
|
||||
<span className="text-[13px] font-medium text-foreground" style={{ marginLeft: 4 }}>{label}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center" style={{ gap: 2 }}>
|
||||
{showCreate && (
|
||||
<button className="flex shrink-0 items-center justify-center rounded border-none bg-transparent p-0 text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover/section:opacity-100 cursor-pointer" style={{ width: 20, height: 20 }} onClick={onCreate} aria-label={type === 'Type' ? 'Create new Type' : `Create new ${type}`} title={type === 'Type' ? 'New Type' : `New ${type}`}>
|
||||
<Plus size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button className="flex shrink-0 items-center border-none bg-transparent p-0 text-inherit cursor-pointer" onClick={(e) => { e.stopPropagation(); onToggle() }} aria-label={isCollapsed ? `Expand ${label}` : `Collapse ${label}`}>
|
||||
{isCollapsed ? <ChevronRight size={12} /> : <ChevronDown size={12} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionChildItem({ title, icon, isActive, sectionColor, sectionLightColor, onClick }: {
|
||||
title: string; icon?: string | null; isActive: boolean
|
||||
sectionColor?: string; sectionLightColor?: string
|
||||
onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn("cursor-pointer truncate rounded-md text-[13px] font-normal transition-colors", isActive ? "text-foreground" : "text-muted-foreground hover:bg-accent")}
|
||||
style={{ padding: '4px 16px 4px 28px', ...(isActive && { backgroundColor: sectionLightColor, color: sectionColor }) }}
|
||||
onClick={onClick}
|
||||
>
|
||||
{icon && isEmoji(icon) && <span className="mr-1">{icon}</span>}{title}
|
||||
{itemCount > 0 && (
|
||||
<span className="flex items-center justify-center text-muted-foreground" style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}>
|
||||
{itemCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user