Compare commits
2 Commits
v0.2026041
...
v0.2026041
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42b1e18cc4 | ||
|
|
334d8bec66 |
@@ -1,4 +1,4 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { render, screen, fireEvent, within } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { Sidebar } from './Sidebar'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
@@ -910,6 +910,53 @@ describe('Sidebar', () => {
|
||||
fireEvent.click(screen.getByText('My Favorite Note'))
|
||||
expect(onSelect).toHaveBeenCalledWith({ kind: 'filter', filter: 'favorites' })
|
||||
})
|
||||
|
||||
it('matches the Types row styling and type color for favorites', () => {
|
||||
render(<Sidebar entries={[...mockEntries, favEntry]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const favoriteLabel = screen.getByText('My Favorite Note')
|
||||
const favoriteRow = favoriteLabel.closest('.cursor-pointer')
|
||||
const typeLabel = screen.getByText('Projects')
|
||||
const typeRow = typeLabel.closest('.cursor-pointer')
|
||||
const favoriteIcon = favoriteRow?.querySelector('svg')
|
||||
|
||||
expect(favoriteRow?.className).toBe(typeRow?.className)
|
||||
expect(favoriteRow?.style.padding).toBe(typeRow?.style.padding)
|
||||
expect(favoriteRow?.style.gap).toBe(typeRow?.style.gap)
|
||||
expect(favoriteLabel.className).toContain(typeLabel.className)
|
||||
expect(favoriteLabel.className).toContain('truncate')
|
||||
expect(favoriteIcon?.getAttribute('width')).toBe('16')
|
||||
expect(favoriteIcon?.getAttribute('height')).toBe('16')
|
||||
expect(favoriteIcon?.getAttribute('style')).toContain('var(--accent-red)')
|
||||
})
|
||||
|
||||
it('falls back to a neutral icon color when the favorite type has no defined color', () => {
|
||||
const customType: VaultEntry = {
|
||||
path: '/vault/types/recipe.md', filename: 'recipe.md', title: 'Recipe',
|
||||
isA: 'Type', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 120, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: 'flask', color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
}
|
||||
const recipeFavorite: VaultEntry = {
|
||||
path: '/vault/recipe/sourdough.md', filename: 'sourdough.md', title: 'Sourdough',
|
||||
isA: 'Recipe', aliases: [], belongsTo: [], relatedTo: [], status: null, owner: null,
|
||||
cadence: null, archived: false, modifiedAt: 1700000000,
|
||||
createdAt: null, fileSize: 120, snippet: '', wordCount: 0, relationships: {},
|
||||
icon: null, color: null, order: null, sidebarLabel: null, template: null,
|
||||
sort: null, view: null, visible: null, outgoingLinks: [], properties: {},
|
||||
favorite: true, favoriteIndex: 0,
|
||||
}
|
||||
|
||||
render(<Sidebar entries={[...mockEntries, customType, recipeFavorite]} selection={defaultSelection} onSelect={() => {}} />)
|
||||
|
||||
const recipeRow = screen.getByText('Sourdough').closest('.cursor-pointer')
|
||||
const recipeIcon = recipeRow?.querySelector('svg')
|
||||
|
||||
expect(recipeIcon?.getAttribute('style')).toContain('var(--muted-foreground)')
|
||||
expect(within(recipeRow as HTMLElement).getByText('Sourdough')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('group separators', () => {
|
||||
@@ -1049,5 +1096,32 @@ describe('Sidebar', () => {
|
||||
const viewContainer = screen.getByText('Empty View').closest('div')
|
||||
expect(viewContainer?.querySelector('span:last-child')?.textContent).not.toBe('0')
|
||||
})
|
||||
|
||||
it('adds hover and focus classes that hide the view count chip while showing the action buttons', () => {
|
||||
render(
|
||||
<Sidebar
|
||||
entries={mockEntries}
|
||||
selection={defaultSelection}
|
||||
onSelect={() => {}}
|
||||
views={mockViews}
|
||||
onEditView={() => {}}
|
||||
onDeleteView={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
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
|
||||
expect(countChip).toBeTruthy()
|
||||
expect(viewItem.className).toContain('[&>div>span:last-child]:transition-opacity')
|
||||
expect(viewItem.className).toContain('group-hover:[&>div>span:last-child]:opacity-0')
|
||||
expect(viewItem.className).toContain('group-focus-within:[&>div>span:last-child]:opacity-0')
|
||||
|
||||
const actionButton = within(viewItem).getByTitle('Edit view')
|
||||
const actionContainer = actionButton.parentElement as HTMLElement
|
||||
expect(actionContainer.className).toContain('group-hover:opacity-100')
|
||||
expect(actionContainer.className).toContain('group-focus-within:opacity-100')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from '../SidebarParts'
|
||||
import { TypeCustomizePopover } from '../TypeCustomizePopover'
|
||||
import { useDragRegion } from '../../hooks/useDragRegion'
|
||||
import { buildTypeEntryMap, getTypeColor, getTypeLightColor } from '../../utils/typeColors'
|
||||
import { NoteTitleIcon } from '../NoteTitleIcon'
|
||||
|
||||
export interface SidebarSectionProps {
|
||||
@@ -86,7 +87,7 @@ function ViewItem({
|
||||
const count = useMemo(() => evaluateView(view.definition, entries).length, [view.definition, entries])
|
||||
|
||||
return (
|
||||
<div className="group relative">
|
||||
<div className="group relative [&>div>span:last-child]:transition-opacity group-hover:[&>div>span:last-child]:opacity-0 group-focus-within:[&>div>span:last-child]:opacity-0">
|
||||
<NavItem
|
||||
icon={Funnel}
|
||||
emoji={view.definition.icon}
|
||||
@@ -95,7 +96,7 @@ function ViewItem({
|
||||
isActive={isActive}
|
||||
onClick={onSelect}
|
||||
/>
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100 group-focus-within:opacity-100">
|
||||
{onEditView && (
|
||||
<button
|
||||
className="rounded p-0.5 text-muted-foreground hover:text-foreground"
|
||||
@@ -334,16 +335,46 @@ export function TypesSection({
|
||||
)
|
||||
}
|
||||
|
||||
const FAVORITE_TYPE_ICON_MAP: Record<string, string> = {
|
||||
Project: 'wrench',
|
||||
project: 'wrench',
|
||||
Experiment: 'flask',
|
||||
experiment: 'flask',
|
||||
Responsibility: 'target',
|
||||
responsibility: 'target',
|
||||
Procedure: 'arrows-clockwise',
|
||||
procedure: 'arrows-clockwise',
|
||||
Person: 'users',
|
||||
person: 'users',
|
||||
Event: 'calendar-blank',
|
||||
event: 'calendar-blank',
|
||||
Topic: 'tag',
|
||||
topic: 'tag',
|
||||
Type: 'stack-simple',
|
||||
type: 'stack-simple',
|
||||
}
|
||||
|
||||
function getFavoriteIcon(entry: VaultEntry, typeEntryMap: Record<string, VaultEntry>) {
|
||||
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
|
||||
return typeEntry?.icon ?? FAVORITE_TYPE_ICON_MAP[entry.isA ?? ''] ?? 'file-text'
|
||||
}
|
||||
|
||||
function SortableFavoriteItem({
|
||||
entry,
|
||||
isActive,
|
||||
onSelect,
|
||||
typeEntryMap,
|
||||
}: {
|
||||
entry: VaultEntry
|
||||
isActive: boolean
|
||||
onSelect: () => void
|
||||
typeEntryMap: Record<string, VaultEntry>
|
||||
}) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: entry.path })
|
||||
const typeEntry = entry.isA ? typeEntryMap[entry.isA] : undefined
|
||||
const icon = getFavoriteIcon(entry, typeEntryMap)
|
||||
const typeColor = getTypeColor(entry.isA ?? null, typeEntry?.color)
|
||||
const typeLightColor = getTypeLightColor(entry.isA ?? null, typeEntry?.color)
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -353,17 +384,36 @@ function SortableFavoriteItem({
|
||||
{...listeners}
|
||||
>
|
||||
<div
|
||||
className={`flex cursor-pointer select-none items-center gap-1.5 rounded text-[13px] font-normal transition-colors ${isActive ? 'bg-secondary text-foreground' : 'text-muted-foreground hover:bg-accent'}`}
|
||||
style={{ padding: '4px 16px 4px 28px' }}
|
||||
className={`group/section flex cursor-pointer select-none items-center justify-between rounded transition-colors ${isActive ? '' : 'hover:bg-accent'}`}
|
||||
style={{ padding: '6px 8px 6px 16px', borderRadius: 4, gap: 4, ...(isActive ? { background: typeLightColor } : {}) }}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<NoteTitleIcon icon={entry.icon} size={14} />
|
||||
<span className="truncate">{entry.title}</span>
|
||||
<div className="flex min-w-0 flex-1 items-center" style={{ gap: 4 }}>
|
||||
<NoteTitleIcon icon={icon} size={16} color={typeColor} />
|
||||
<span className="truncate text-[13px] font-medium" style={{ marginLeft: 4, color: isActive ? typeColor : undefined }}>
|
||||
{entry.title}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function sortFavorites(entries: VaultEntry[]) {
|
||||
return entries
|
||||
.filter((entry) => entry.favorite && !entry.archived)
|
||||
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity))
|
||||
}
|
||||
|
||||
function reorderFavoriteIds(favoriteIds: string[], event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return null
|
||||
const oldIndex = favoriteIds.indexOf(active.id as string)
|
||||
const newIndex = favoriteIds.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return null
|
||||
return arrayMove(favoriteIds, oldIndex, newIndex)
|
||||
}
|
||||
|
||||
export function FavoritesSection({
|
||||
entries,
|
||||
selection,
|
||||
@@ -381,22 +431,14 @@ export function FavoritesSection({
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const favorites = useMemo(
|
||||
() => entries
|
||||
.filter((entry) => entry.favorite && !entry.archived)
|
||||
.sort((a, b) => (a.favoriteIndex ?? Infinity) - (b.favoriteIndex ?? Infinity)),
|
||||
[entries],
|
||||
)
|
||||
const favorites = useMemo(() => sortFavorites(entries), [entries])
|
||||
const favoriteIds = useMemo(() => favorites.map((entry) => entry.path), [favorites])
|
||||
const typeEntryMap = useMemo(() => buildTypeEntryMap(entries), [entries])
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }))
|
||||
|
||||
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
if (!over || active.id === over.id) return
|
||||
const oldIndex = favoriteIds.indexOf(active.id as string)
|
||||
const newIndex = favoriteIds.indexOf(over.id as string)
|
||||
if (oldIndex === -1 || newIndex === -1) return
|
||||
onReorder?.(arrayMove(favoriteIds, oldIndex, newIndex))
|
||||
const reordered = reorderFavoriteIds(favoriteIds, event)
|
||||
if (reordered) onReorder?.(reordered)
|
||||
}, [favoriteIds, onReorder])
|
||||
|
||||
if (favorites.length === 0) return null
|
||||
@@ -413,6 +455,7 @@ export function FavoritesSection({
|
||||
key={entry.path}
|
||||
entry={entry}
|
||||
isActive={isSelectionActive(selection, { kind: 'entity', entry })}
|
||||
typeEntryMap={typeEntryMap}
|
||||
onSelect={() => {
|
||||
onSelect({ kind: 'filter', filter: 'favorites' })
|
||||
onSelectNote?.(entry)
|
||||
|
||||
Reference in New Issue
Block a user