fix: align sidebar view count chips

This commit is contained in:
lucaronin
2026-04-14 19:45:32 +02:00
parent cd76c0d65d
commit 1388e7ddbe
3 changed files with 116 additions and 58 deletions

View File

@@ -1129,16 +1129,55 @@ describe('Sidebar', () => {
const projectLabel = screen.getByText('Active Projects')
const projectNavItem = projectLabel.closest('[class*="cursor-pointer"]')!
// The count chip is a sibling span inside the NavItem
const projectCount = projectNavItem.querySelector('span:last-child')
const projectCount = within(projectNavItem as HTMLElement).getByTestId('view-count-chip')
expect(projectCount?.textContent).toBe('1')
// 'All Topics' filters for type=Topic -> mockEntries has 2 Topics
const topicLabel = screen.getByText('All Topics')
const topicNavItem = topicLabel.closest('[class*="cursor-pointer"]')!
const topicCount = topicNavItem.querySelector('span:last-child')
const topicCount = within(topicNavItem as HTMLElement).getByTestId('view-count-chip')
expect(topicCount?.textContent).toBe('2')
})
it('styles view note count chips like the shared sidebar count pills', () => {
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={mockViews} />
)
const viewLabel = screen.getByText('Active Projects')
const navItem = viewLabel.closest('[class*="cursor-pointer"]') as HTMLElement
const countChip = within(navItem).getByTestId('view-count-chip')
expect(navItem).toHaveStyle({ padding: '6px 8px 6px 16px' })
expect(countChip).toHaveStyle({
background: 'var(--muted)',
height: '20px',
padding: '0 6px',
})
expect(countChip.className).toContain('text-muted-foreground')
})
it('renders phosphor view icons without leaking the raw icon name', () => {
const iconViews = [{
filename: 'active-projects.yml',
definition: {
name: 'Active Projects',
icon: 'rocket',
color: null,
sort: null,
filters: { all: [{ field: 'type', op: 'equals' as const, value: 'Project' }] },
},
}]
render(
<Sidebar entries={mockEntries} selection={defaultSelection} onSelect={() => {}} views={iconViews} />
)
const navItem = screen.getByText('Active Projects').closest('[class*="cursor-pointer"]') as HTMLElement
expect(navItem.querySelector('svg')).not.toBeNull()
expect(screen.queryByText('rocket')).not.toBeInTheDocument()
})
it('does not show count chip for views with 0 matching notes', () => {
const emptyView = [{
filename: 'empty.yml',
@@ -1174,7 +1213,7 @@ describe('Sidebar', () => {
const label = screen.getByText('Active Projects')
const viewItem = label.closest('.group.relative') as HTMLElement
const navItem = label.closest('[class*="cursor-pointer"]') as HTMLElement
const countChip = navItem.querySelector('span:last-child') as HTMLElement
const countChip = within(navItem).getByTestId('view-count-chip')
expect(countChip).toBeTruthy()
expect(countChip.className).toContain('transition-opacity')
expect(countChip.className).toContain('group-hover:opacity-0')

View File

@@ -2,7 +2,6 @@ import {
useMemo, useCallback, type Dispatch, type Ref, type RefObject, type SetStateAction,
} from 'react'
import type { VaultEntry, SidebarSelection, ViewFile } from '../../types'
import { evaluateView } from '../../utils/viewFilters'
import {
DndContext, closestCenter, PointerSensor, useSensor, useSensors, type DragEndEvent,
} from '@dnd-kit/core'
@@ -13,7 +12,7 @@ import { arrayMove } from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { SlidersHorizontal } from 'lucide-react'
import {
FileText, Trash, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus, Funnel, PencilSimple,
FileText, Archive, CaretLeft, Tray, CaretRight, CaretDown, Plus,
} from '@phosphor-icons/react'
import {
type SectionGroup, isSelectionActive, NavItem, SectionContent, VisibilityPopover,
@@ -22,6 +21,7 @@ import { TypeCustomizePopover } from '../TypeCustomizePopover'
import { useDragRegion } from '../../hooks/useDragRegion'
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { NoteTitleIcon } from '../NoteTitleIcon'
import { SidebarViewItem } from './SidebarViewItem'
export interface SidebarSectionProps {
entries: VaultEntry[]
@@ -69,58 +69,6 @@ function SidebarGroupHeader({
)
}
function ViewItem({
view,
isActive,
onSelect,
onEditView,
onDeleteView,
entries,
}: {
view: ViewFile
isActive: boolean
onSelect: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
entries: VaultEntry[]
}) {
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
return (
<div className="group relative">
<NavItem
icon={Funnel}
emoji={view.definition.icon}
label={view.definition.name}
count={count}
badgeClassName="transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
isActive={isActive}
onClick={onSelect}
/>
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(event) => { event.stopPropagation(); onEditView(view.filename) }}
title="Edit view"
>
<PencilSimple size={12} />
</button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
onClick={(event) => { event.stopPropagation(); onDeleteView(view.filename) }}
title="Delete view"
>
<Trash size={12} />
</button>
)}
</div>
</div>
)
}
export function SidebarTopNav({
selection,
onSelect,
@@ -209,7 +157,7 @@ export function ViewsSection({
{!collapsed && (
<div style={{ paddingBottom: 4 }}>
{views.map((view) => (
<ViewItem
<SidebarViewItem
key={view.filename}
view={view}
isActive={isSelectionActive(selection, { kind: 'view', filename: view.filename })}

View File

@@ -0,0 +1,71 @@
import { useMemo } from 'react'
import type { VaultEntry, ViewFile } from '../../types'
import { evaluateView } from '../../utils/viewFilters'
import { Funnel, PencilSimple, Trash } from '@phosphor-icons/react'
import { NoteTitleIcon } from '../NoteTitleIcon'
interface SidebarViewItemProps {
view: ViewFile
isActive: boolean
onSelect: () => void
onEditView?: (filename: string) => void
onDeleteView?: (filename: string) => void
entries: VaultEntry[]
}
export function SidebarViewItem({
view,
isActive,
onSelect,
onEditView,
onDeleteView,
entries,
}: SidebarViewItemProps) {
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
const showCount = count > 0
const icon = view.definition.icon
? <NoteTitleIcon icon={view.definition.icon} size={16} />
: <Funnel size={16} weight={isActive ? 'fill' : 'regular'} />
return (
<div className="group relative">
<div
className={`flex cursor-pointer select-none items-center gap-2 rounded transition-colors ${isActive ? 'bg-primary/10 text-primary' : 'text-foreground hover:bg-accent'}`}
style={{ padding: showCount ? '6px 8px 6px 16px' : '6px 16px', borderRadius: 4 }}
onClick={onSelect}
>
{icon}
<span className="min-w-0 flex-1 truncate text-[13px] font-medium">{view.definition.name}</span>
{showCount && (
<span
data-testid="view-count-chip"
className="flex items-center justify-center text-muted-foreground transition-opacity group-hover:opacity-0 group-focus-within:opacity-0"
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}
>
{count}
</span>
)}
</div>
<div className="pointer-events-none absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100 group-focus-within:pointer-events-auto group-focus-within:opacity-100">
{onEditView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
onClick={(event) => { event.stopPropagation(); onEditView(view.filename) }}
title="Edit view"
>
<PencilSimple size={12} />
</button>
)}
{onDeleteView && (
<button
className="rounded p-0.5 text-muted-foreground hover:text-destructive"
onClick={(event) => { event.stopPropagation(); onDeleteView(view.filename) }}
title="Delete view"
>
<Trash size={12} />
</button>
)}
</div>
</div>
)
}