Files
tolaria/src/components/SortDropdown.tsx

408 lines
12 KiB
TypeScript
Raw Normal View History

2026-04-18 20:31:25 +02:00
import { useState, useEffect, useMemo, useRef, useCallback } from 'react'
import { cn } from '@/lib/utils'
import { ArrowUp, ArrowDown } from '@phosphor-icons/react'
2026-04-26 19:37:21 +02:00
import { translate, type AppLocale, type TranslationKey } from '../lib/i18n'
import { type SortOption, type SortDirection, getDefaultDirection, SORT_OPTIONS } from '../utils/noteListHelpers'
2026-04-18 20:31:25 +02:00
interface SortItem {
value: SortOption
label: string
}
2026-04-26 19:37:21 +02:00
const SORT_LABEL_KEYS = {
modified: 'noteList.sort.modified',
created: 'noteList.sort.created',
title: 'noteList.sort.title',
status: 'noteList.sort.status',
} satisfies Record<string, TranslationKey>
2026-04-18 20:31:25 +02:00
type SortMenuAction =
| { type: 'close' }
| { type: 'focus'; index: number }
2026-04-26 19:37:21 +02:00
function getLocalizedSortOptionLabel(option: SortOption, locale: AppLocale): string {
if (option.startsWith('property:')) return option.slice('property:'.length)
return translate(locale, SORT_LABEL_KEYS[option as keyof typeof SORT_LABEL_KEYS])
}
function buildSortItems(locale: AppLocale, customProperties?: string[]): SortItem[] {
const builtInItems = SORT_OPTIONS.map(({ value }) => ({ value, label: getLocalizedSortOptionLabel(value, locale) }))
2026-04-18 20:31:25 +02:00
const customItems = (customProperties ?? []).map((key) => ({
value: `property:${key}` as SortOption,
label: key,
}))
return [...builtInItems, ...customItems]
}
function resolveFocusedIndex(groupLabel: string, current: SortOption, sortItems: SortItem[]) {
const activeElement = document.activeElement as HTMLElement | null
const activeIndex = Number(activeElement?.dataset.sortItemIndex ?? -1)
if (activeElement?.dataset.sortGroupLabel === groupLabel && activeIndex >= 0) return activeIndex
const currentIndex = sortItems.findIndex((item) => item.value === current)
return currentIndex >= 0 ? currentIndex : 0
}
function focusSortItem(sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>, index: number) {
sortButtonRefs.current[index]?.focus()
}
function resolveSortMenuAction(key: string, focusIndex: number, itemCount: number): SortMenuAction | null {
const lastIndex = itemCount - 1
switch (key) {
case 'Escape':
return { type: 'close' }
case 'ArrowDown':
return { type: 'focus', index: Math.min(lastIndex, focusIndex + 1) }
case 'ArrowUp':
return { type: 'focus', index: Math.max(0, focusIndex - 1) }
case 'Home':
return { type: 'focus', index: 0 }
case 'End':
return { type: 'focus', index: lastIndex }
default:
return null
}
}
function selectOnKeyboard(
event: React.KeyboardEvent<HTMLButtonElement>,
value: SortOption,
direction: SortDirection,
onSelect: (opt: SortOption, dir: SortDirection) => void,
) {
if (event.key !== 'Enter' && event.key !== ' ') return
event.preventDefault()
onSelect(value, direction)
}
function getDirectionButtonClass(isActive: boolean, activeDirection: SortDirection, buttonDirection: SortDirection) {
return cn(
'flex items-center rounded p-0.5 hover:bg-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
isActive && activeDirection === buttonDirection ? 'text-foreground' : 'text-muted-foreground opacity-40',
)
}
function useSortDropdownState({
groupLabel,
current,
sortItems,
onChange,
}: {
groupLabel: string
current: SortOption
2026-04-18 20:31:25 +02:00
sortItems: SortItem[]
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
const [open, setOpen] = useState(false)
2026-04-18 20:31:25 +02:00
const containerRef = useRef<HTMLDivElement>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const sortButtonRefs = useRef<Array<HTMLButtonElement | null>>([])
useEffect(() => {
if (!open) return
2026-04-18 20:31:25 +02:00
function handlePointerDown(event: MouseEvent) {
if (containerRef.current?.contains(event.target as Node)) return
setOpen(false)
}
2026-04-18 20:31:25 +02:00
document.addEventListener('mousedown', handlePointerDown)
return () => document.removeEventListener('mousedown', handlePointerDown)
}, [open])
2026-04-18 20:31:25 +02:00
useEffect(() => {
if (!open) return
focusSortItem(sortButtonRefs, resolveFocusedIndex(groupLabel, current, sortItems))
}, [current, groupLabel, open, sortItems])
const closeMenu = useCallback(() => {
setOpen(false)
2026-04-18 20:31:25 +02:00
triggerRef.current?.focus()
}, [])
const handleSelect = useCallback((option: SortOption, nextDirection: SortDirection) => {
onChange(groupLabel, option, nextDirection)
closeMenu()
}, [closeMenu, groupLabel, onChange])
const handleMenuKeyDown = useCallback((event: React.KeyboardEvent<HTMLDivElement>) => {
const action = resolveSortMenuAction(
event.key,
resolveFocusedIndex(groupLabel, current, sortItems),
sortItems.length,
)
if (!action) return
event.preventDefault()
if (action.type === 'close') {
closeMenu()
return
}
focusSortItem(sortButtonRefs, action.index)
}, [closeMenu, current, groupLabel, sortItems])
return {
open,
setOpen,
containerRef,
triggerRef,
sortButtonRefs,
handleSelect,
handleMenuKeyDown,
}
2026-04-18 20:31:25 +02:00
}
2026-04-18 20:31:25 +02:00
function SortDropdownTrigger({
triggerRef,
open,
current,
groupLabel,
direction,
2026-04-26 19:37:21 +02:00
locale,
2026-04-18 20:31:25 +02:00
onToggle,
}: {
triggerRef: React.RefObject<HTMLButtonElement | null>
open: boolean
current: SortOption
groupLabel: string
direction: SortDirection
2026-04-26 19:37:21 +02:00
locale: AppLocale
2026-04-18 20:31:25 +02:00
onToggle: () => void
}) {
const DirectionIcon = direction === 'asc' ? ArrowUp : ArrowDown
2026-04-26 19:37:21 +02:00
const currentLabel = getLocalizedSortOptionLabel(current, locale)
return (
2026-04-18 20:31:25 +02:00
<button
ref={triggerRef}
type="button"
className={cn('flex items-center gap-0.5 rounded px-1 py-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground', open && 'bg-accent text-foreground')}
onClick={(event) => {
event.stopPropagation()
onToggle()
}}
2026-04-26 19:37:21 +02:00
title={translate(locale, 'noteList.sort.by', { label: currentLabel })}
2026-04-18 20:31:25 +02:00
aria-haspopup="menu"
aria-expanded={open}
data-testid={`sort-button-${groupLabel}`}
>
<DirectionIcon size={12} data-testid={`sort-direction-icon-${groupLabel}`} />
2026-04-26 19:37:21 +02:00
<span className="text-[10px] font-medium">{currentLabel}</span>
2026-04-18 20:31:25 +02:00
</button>
)
}
function SortDropdownMenu({
open,
groupLabel,
current,
direction,
sortItems,
sortButtonRefs,
2026-04-26 19:37:21 +02:00
locale,
2026-04-18 20:31:25 +02:00
onKeyDown,
onSelect,
}: {
open: boolean
groupLabel: string
current: SortOption
direction: SortDirection
sortItems: SortItem[]
sortButtonRefs: React.MutableRefObject<Array<HTMLButtonElement | null>>
2026-04-26 19:37:21 +02:00
locale: AppLocale
2026-04-18 20:31:25 +02:00
onKeyDown: (event: React.KeyboardEvent<HTMLDivElement>) => void
onSelect: (option: SortOption, nextDirection: SortDirection) => void
}) {
if (!open) return null
const hasCustom = sortItems.length > SORT_OPTIONS.length
const builtInOptionCount = SORT_OPTIONS.length
return (
<div
role="menu"
2026-04-26 19:37:21 +02:00
aria-label={translate(locale, 'noteList.sort.menu', { label: groupLabel })}
2026-04-18 20:31:25 +02:00
className="absolute right-0 top-full mt-1 rounded-md border border-border bg-popover p-1 shadow-md"
style={{ width: 170, maxHeight: 280, overflowY: 'auto' }}
onKeyDown={onKeyDown}
data-testid={`sort-menu-${groupLabel}`}
>
{sortItems.map((item, index) => (
<SortRow
key={item.value}
index={index}
groupLabel={groupLabel}
value={item.value}
label={item.label}
current={current}
direction={direction}
buttonRef={(node) => {
sortButtonRefs.current[index] = node
}}
showSeparator={hasCustom && index === builtInOptionCount}
2026-04-26 19:37:21 +02:00
locale={locale}
2026-04-18 20:31:25 +02:00
onSelect={onSelect}
/>
))}
</div>
)
}
2026-04-26 19:37:21 +02:00
export function SortDropdown({ groupLabel, current, direction, customProperties, locale = 'en', onChange }: {
2026-04-18 20:31:25 +02:00
groupLabel: string
current: SortOption
direction: SortDirection
customProperties?: string[]
2026-04-26 19:37:21 +02:00
locale?: AppLocale
2026-04-18 20:31:25 +02:00
onChange: (groupLabel: string, option: SortOption, direction: SortDirection) => void
}) {
2026-04-26 19:37:21 +02:00
const sortItems = useMemo(() => buildSortItems(locale, customProperties), [customProperties, locale])
2026-04-18 20:31:25 +02:00
const {
open,
setOpen,
containerRef,
triggerRef,
sortButtonRefs,
handleSelect,
handleMenuKeyDown,
} = useSortDropdownState({
groupLabel,
current,
sortItems,
onChange,
})
return (
<div ref={containerRef} className="relative" style={{ zIndex: open ? 10 : 0 }}>
<SortDropdownTrigger
triggerRef={triggerRef}
open={open}
current={current}
groupLabel={groupLabel}
direction={direction}
2026-04-26 19:37:21 +02:00
locale={locale}
2026-04-18 20:31:25 +02:00
onToggle={() => setOpen((value) => !value)}
/>
<SortDropdownMenu
open={open}
groupLabel={groupLabel}
current={current}
direction={direction}
sortItems={sortItems}
sortButtonRefs={sortButtonRefs}
2026-04-26 19:37:21 +02:00
locale={locale}
2026-04-18 20:31:25 +02:00
onKeyDown={handleMenuKeyDown}
onSelect={handleSelect}
/>
</div>
)
}
2026-04-26 19:37:21 +02:00
function SortRow({ index, groupLabel, value, label, current, direction, buttonRef, showSeparator, locale, onSelect }: {
2026-04-18 20:31:25 +02:00
index: number
groupLabel: string
value: SortOption
label: string
current: SortOption
direction: SortDirection
2026-04-18 20:31:25 +02:00
buttonRef: (node: HTMLButtonElement | null) => void
showSeparator: boolean
2026-04-26 19:37:21 +02:00
locale: AppLocale
onSelect: (opt: SortOption, dir: SortDirection) => void
}) {
const isActive = value === current
2026-04-18 20:31:25 +02:00
const defaultDirection = isActive ? direction : getDefaultDirection(value)
const itemData = {
'data-sort-group-label': groupLabel,
'data-sort-item-index': String(index),
}
return (
2026-04-18 20:31:25 +02:00
<>
{showSeparator && <div className="mx-2 my-1 border-t border-border" data-testid="sort-separator" />}
<div
className={cn('flex items-center justify-between gap-1 rounded px-1 text-[12px] text-popover-foreground hover:bg-accent', isActive && 'bg-accent font-medium')}
style={{ minHeight: 28 }}
>
<button
2026-04-18 20:31:25 +02:00
ref={buttonRef}
type="button"
role="menuitemradio"
aria-checked={isActive}
className="flex min-w-0 flex-1 items-center gap-1.5 rounded px-1 py-1 text-left text-inherit hover:bg-background/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={(event) => {
event.stopPropagation()
onSelect(value, defaultDirection)
}}
onKeyDown={(event) => selectOnKeyboard(event, value, defaultDirection, onSelect)}
data-testid={`sort-option-${value}`}
{...itemData}
>
2026-04-18 20:31:25 +02:00
<span className="truncate">{label}</span>
</button>
2026-04-18 20:31:25 +02:00
<span className="flex items-center gap-0.5">
<SortDirectionButton
value={value}
direction="asc"
activeDirection={direction}
isActive={isActive}
onSelect={onSelect}
icon={<ArrowUp size={12} />}
itemData={itemData}
2026-04-26 19:37:21 +02:00
locale={locale}
2026-04-18 20:31:25 +02:00
/>
<SortDirectionButton
value={value}
direction="desc"
activeDirection={direction}
isActive={isActive}
onSelect={onSelect}
icon={<ArrowDown size={12} />}
itemData={itemData}
2026-04-26 19:37:21 +02:00
locale={locale}
2026-04-18 20:31:25 +02:00
/>
</span>
</div>
</>
)
}
function SortDirectionButton({
value,
direction,
activeDirection,
isActive,
onSelect,
icon,
itemData,
2026-04-26 19:37:21 +02:00
locale,
2026-04-18 20:31:25 +02:00
}: {
value: SortOption
direction: SortDirection
activeDirection: SortDirection
isActive: boolean
onSelect: (opt: SortOption, dir: SortDirection) => void
icon: React.ReactNode
itemData: Record<string, string>
2026-04-26 19:37:21 +02:00
locale: AppLocale
2026-04-18 20:31:25 +02:00
}) {
return (
<button
type="button"
className={getDirectionButtonClass(isActive, activeDirection, direction)}
onClick={(event) => {
event.stopPropagation()
onSelect(value, direction)
}}
data-testid={`sort-dir-${direction}-${value}`}
2026-04-26 19:37:21 +02:00
title={translate(locale, direction === 'asc' ? 'noteList.sort.ascending' : 'noteList.sort.descending')}
2026-04-18 20:31:25 +02:00
{...itemData}
>
{icon}
</button>
)
}