feat: add sort direction (asc/desc) to note list sorting
- Add SortDirection type and SortConfig interface to noteListHelpers
- getSortComparator now accepts optional direction parameter
- SortDropdown shows ↑/↓ arrow buttons per option in the menu
- Button label shows current direction arrow (ArrowUp/ArrowDown)
- Persistence updated to store {option, direction} with backward compat
for old string-only preferences
- Default directions: modified/created=desc, title/status=asc
- Status sort tiebreaker always uses newest-first regardless of direction
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { ArrowsDownUp, Check } from '@phosphor-icons/react'
|
||||
import { type SortOption, SORT_OPTIONS } from '../utils/noteListHelpers'
|
||||
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
|
||||
import { type SortOption, type SortDirection, DEFAULT_DIRECTIONS, SORT_OPTIONS } from '../utils/noteListHelpers'
|
||||
|
||||
export function SortDropdown({ groupLabel, current, onChange }: {
|
||||
export function SortDropdown({ groupLabel, current, direction, onChange }: {
|
||||
groupLabel: string
|
||||
current: SortOption
|
||||
onChange: (groupLabel: string, option: SortOption) => void
|
||||
direction: SortDirection
|
||||
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
@@ -20,11 +21,13 @@ export function SortDropdown({ groupLabel, current, onChange }: {
|
||||
return () => document.removeEventListener('mousedown', handleClick)
|
||||
}, [open])
|
||||
|
||||
const handleSelect = (opt: SortOption) => {
|
||||
onChange(groupLabel, opt)
|
||||
const handleSelect = (opt: SortOption, dir: SortDirection) => {
|
||||
onChange(groupLabel, opt, dir)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative" style={{ zIndex: open ? 10 : 0 }}>
|
||||
<button
|
||||
@@ -33,23 +36,47 @@ export function SortDropdown({ groupLabel, current, onChange }: {
|
||||
title={`Sort by ${current}`}
|
||||
data-testid={`sort-button-${groupLabel}`}
|
||||
>
|
||||
<ArrowsDownUp size={12} />
|
||||
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
|
||||
<span className="text-[10px] font-medium">{SORT_OPTIONS.find((o) => o.value === current)?.label}</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md" style={{ width: 130, padding: 4 }} data-testid={`sort-menu-${groupLabel}`}>
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
className={cn("flex w-full items-center gap-1.5 rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", opt.value === current && "bg-accent font-medium")}
|
||||
style={{ height: 28, border: 'none', cursor: 'pointer', background: opt.value === current ? 'var(--accent)' : 'transparent' }}
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value) }}
|
||||
data-testid={`sort-option-${opt.value}`}
|
||||
>
|
||||
{opt.value === current ? <Check size={12} /> : <span style={{ width: 12, height: 12, display: 'inline-block' }} />}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
<div className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover shadow-md" style={{ width: 150, padding: 4 }} data-testid={`sort-menu-${groupLabel}`}>
|
||||
{SORT_OPTIONS.map((opt) => {
|
||||
const isActive = opt.value === current
|
||||
return (
|
||||
<div
|
||||
key={opt.value}
|
||||
className={cn("flex w-full items-center justify-between rounded px-2 text-[12px] text-popover-foreground hover:bg-accent", isActive && "bg-accent font-medium")}
|
||||
style={{ height: 28, cursor: 'pointer', background: isActive ? 'var(--accent)' : 'transparent' }}
|
||||
data-testid={`sort-option-${opt.value}`}
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, isActive ? direction : DEFAULT_DIRECTIONS[opt.value]) }}
|
||||
>
|
||||
<span className="flex flex-1 items-center gap-1.5 text-inherit">
|
||||
{opt.label}
|
||||
</span>
|
||||
<span className="flex items-center gap-0.5 ml-1">
|
||||
<button
|
||||
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'asc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
|
||||
style={{ padding: 2 }}
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, 'asc') }}
|
||||
data-testid={`sort-dir-asc-${opt.value}`}
|
||||
title="Ascending"
|
||||
>
|
||||
<ArrowUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
className={cn("flex items-center border-none bg-transparent cursor-pointer p-0 rounded hover:bg-background", isActive && direction === 'desc' ? 'text-foreground' : 'text-muted-foreground opacity-40')}
|
||||
style={{ padding: 2 }}
|
||||
onClick={(e) => { e.stopPropagation(); handleSelect(opt.value, 'desc') }}
|
||||
data-testid={`sort-dir-desc-${opt.value}`}
|
||||
title="Descending"
|
||||
>
|
||||
<ArrowDown size={12} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user