feat: add filter pills (Open/Archived/Trashed) with count badges to note list
When viewing a type in the sidebar, the note list now shows filter pills below the header to switch between Open, Archived, and Trashed notes. Each pill shows a count badge. Bulk actions are context-aware: Trashed filter offers Restore/Archive/Delete permanently, Archived filter offers Unarchive/Trash. Cmd+K commands added for switching filters. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
43
src/components/note-list/FilterPills.tsx
Normal file
43
src/components/note-list/FilterPills.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import { memo } from 'react'
|
||||
import type { NoteListFilter } from '../../utils/noteListHelpers'
|
||||
|
||||
interface FilterPillsProps {
|
||||
active: NoteListFilter
|
||||
counts: Record<NoteListFilter, number>
|
||||
onChange: (filter: NoteListFilter) => void
|
||||
}
|
||||
|
||||
const PILLS: { value: NoteListFilter; label: string }[] = [
|
||||
{ value: 'open', label: 'Open' },
|
||||
{ value: 'archived', label: 'Archived' },
|
||||
{ value: 'trashed', label: 'Trashed' },
|
||||
]
|
||||
|
||||
function FilterPillsInner({ active, counts, onChange }: FilterPillsProps) {
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-4 py-1.5" data-testid="filter-pills">
|
||||
{PILLS.map(({ value, label }) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active === value}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-[12px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring ${
|
||||
active === value
|
||||
? 'border-foreground/20 bg-foreground/10 text-foreground'
|
||||
: 'border-transparent text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => onChange(value)}
|
||||
data-testid={`filter-pill-${value}`}
|
||||
>
|
||||
{label}
|
||||
<span className={`text-[10px] tabular-nums ${active === value ? 'text-foreground/70' : 'text-muted-foreground/70'}`}>
|
||||
{counts[value]}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FilterPills = memo(FilterPillsInner)
|
||||
@@ -11,10 +11,11 @@ function ListViewHeader({ isTrashView, expiredTrashCount }: {
|
||||
return <TrashWarningBanner expiredCount={isTrashView ? expiredTrashCount : 0} />
|
||||
}
|
||||
|
||||
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, query: string): string {
|
||||
function resolveEmptyText(isChangesView: boolean, changesError: string | null | undefined, isTrashView: boolean, isArchivedView: boolean, query: string): string {
|
||||
if (isChangesView && changesError) return `Failed to load changes: ${changesError}`
|
||||
if (isChangesView) return 'No pending changes'
|
||||
if (isTrashView) return 'Trash is empty'
|
||||
if (isArchivedView) return 'No archived notes'
|
||||
return query ? 'No matching notes' : 'No notes found'
|
||||
}
|
||||
|
||||
@@ -38,13 +39,13 @@ export function EntityView({ entity, groups, query, collapsedGroups, sortPrefs,
|
||||
)
|
||||
}
|
||||
|
||||
export function ListView({ isTrashView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
|
||||
isTrashView: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
|
||||
export function ListView({ isTrashView, isArchivedView, isChangesView, changesError, expiredTrashCount, deletedCount = 0, searched, query, renderItem, virtuosoRef }: {
|
||||
isTrashView: boolean; isArchivedView?: boolean; isChangesView?: boolean; changesError?: string | null; expiredTrashCount: number
|
||||
deletedCount?: number; searched: VaultEntry[]; query: string
|
||||
renderItem: (entry: VaultEntry) => React.ReactNode
|
||||
virtuosoRef?: React.RefObject<VirtuosoHandle | null>
|
||||
}) {
|
||||
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, query)
|
||||
const emptyText = resolveEmptyText(!!isChangesView, changesError ?? null, isTrashView, !!isArchivedView, query)
|
||||
const hasHeader = isTrashView && expiredTrashCount > 0
|
||||
const hasDeletedOnly = !!isChangesView && deletedCount > 0 && searched.length === 0
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
||||
import type { VaultEntry, SidebarSelection, ModifiedFile, NoteStatus } from '../../types'
|
||||
import {
|
||||
type SortOption, type SortDirection, type SortConfig,
|
||||
type SortOption, type SortDirection, type SortConfig, type NoteListFilter,
|
||||
getSortComparator, extractSortableProperties,
|
||||
buildRelationshipGroups, filterEntries,
|
||||
loadSortPreferences, saveSortPreferences,
|
||||
@@ -19,14 +19,14 @@ export function useTypeEntryMap(entries: VaultEntry[]) {
|
||||
|
||||
// --- useFilteredEntries ---
|
||||
|
||||
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[]) {
|
||||
export function useFilteredEntries(entries: VaultEntry[], selection: SidebarSelection, modifiedPathSet: Set<string>, modifiedSuffixes: string[], subFilter?: NoteListFilter) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isChangesView = selection.kind === 'filter' && selection.filter === 'changes'
|
||||
return useMemo(() => {
|
||||
if (isEntityView) return []
|
||||
if (isChangesView) return entries.filter((e) => isModifiedEntry(e.path, modifiedPathSet, modifiedSuffixes))
|
||||
return filterEntries(entries, selection)
|
||||
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes])
|
||||
return filterEntries(entries, selection, subFilter)
|
||||
}, [entries, selection, isEntityView, isChangesView, modifiedPathSet, modifiedSuffixes, subFilter])
|
||||
}
|
||||
|
||||
// --- useNoteListData ---
|
||||
@@ -35,13 +35,15 @@ interface NoteListDataParams {
|
||||
entries: VaultEntry[]; selection: SidebarSelection
|
||||
query: string; listSort: SortOption; listDirection: SortDirection
|
||||
modifiedPathSet: Set<string>; modifiedSuffixes: string[]
|
||||
subFilter?: NoteListFilter
|
||||
}
|
||||
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes }: NoteListDataParams) {
|
||||
export function useNoteListData({ entries, selection, query, listSort, listDirection, modifiedPathSet, modifiedSuffixes, subFilter }: NoteListDataParams) {
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
|
||||
const isTrashView = (selection.kind === 'filter' && selection.filter === 'trash') || subFilter === 'trashed'
|
||||
const isArchivedView = (selection.kind === 'filter' && selection.filter === 'archived') || subFilter === 'archived'
|
||||
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter)
|
||||
|
||||
const searched = useMemo(() => {
|
||||
const sorted = [...filteredEntries].sort(getSortComparator(listSort, listDirection))
|
||||
@@ -59,7 +61,7 @@ export function useNoteListData({ entries, selection, query, listSort, listDirec
|
||||
[isTrashView, searched],
|
||||
)
|
||||
|
||||
return { isEntityView, isTrashView, searched, searchedGroups, expiredTrashCount }
|
||||
return { isEntityView, isTrashView, isArchivedView, searched, searchedGroups, expiredTrashCount }
|
||||
}
|
||||
|
||||
// --- useNoteListSearch ---
|
||||
@@ -122,11 +124,12 @@ export interface UseNoteListSortParams {
|
||||
selection: SidebarSelection
|
||||
modifiedPathSet: Set<string>
|
||||
modifiedSuffixes: string[]
|
||||
subFilter?: NoteListFilter
|
||||
onUpdateTypeSort?: (path: string, key: string, value: string | number | boolean | string[] | null) => void
|
||||
updateEntry?: (path: string, patch: Partial<VaultEntry>) => void
|
||||
}
|
||||
|
||||
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
|
||||
export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedSuffixes, subFilter, onUpdateTypeSort, updateEntry }: UseNoteListSortParams) {
|
||||
const [sortPrefs, setSortPrefs] = useState<Record<string, SortConfig>>(loadSortPreferences)
|
||||
|
||||
const typeDocument = useMemo(() => {
|
||||
@@ -154,7 +157,7 @@ export function useNoteListSort({ entries, selection, modifiedPathSet, modifiedS
|
||||
}
|
||||
}, [typeDocument, persistence])
|
||||
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes)
|
||||
const filteredEntries = useFilteredEntries(entries, selection, modifiedPathSet, modifiedSuffixes, subFilter)
|
||||
const customProperties = useMemo(() => extractSortableProperties(filteredEntries), [filteredEntries])
|
||||
const listSort = useMemo<SortOption>(() => deriveEffectiveSort(listConfig.option, customProperties), [listConfig.option, customProperties])
|
||||
const listDirection = listSort === listConfig.option ? listConfig.direction : 'desc'
|
||||
|
||||
Reference in New Issue
Block a user