2026-05-11 16:37:06 +02:00
|
|
|
import { useRef, useEffect, type ComponentType, type MouseEvent, type PointerEvent, type SVGAttributes } from 'react'
|
2026-02-25 17:24:21 +01:00
|
|
|
import { cn } from '@/lib/utils'
|
|
|
|
|
import { Badge } from '@/components/ui/badge'
|
2026-05-07 19:52:14 +02:00
|
|
|
import { scrollSelectedHTMLChildIntoView } from '../utils/domScroll'
|
2026-04-07 21:09:06 +02:00
|
|
|
import { NoteTitleIcon } from './NoteTitleIcon'
|
2026-05-11 16:37:06 +02:00
|
|
|
import { WorkspaceInitialsBadge } from './WorkspaceInitialsBadge'
|
|
|
|
|
import type { WorkspaceIdentity } from '../types'
|
2026-02-25 17:24:21 +01:00
|
|
|
|
|
|
|
|
export interface NoteSearchResultItem {
|
|
|
|
|
title: string
|
2026-04-07 21:09:06 +02:00
|
|
|
noteIcon?: string | null
|
2026-02-25 17:24:21 +01:00
|
|
|
noteType?: string
|
|
|
|
|
typeColor?: string
|
2026-02-26 18:43:29 +01:00
|
|
|
typeLightColor?: string
|
2026-02-26 04:05:22 +01:00
|
|
|
TypeIcon?: ComponentType<SVGAttributes<SVGSVGElement>>
|
2026-05-11 16:37:06 +02:00
|
|
|
workspace?: WorkspaceIdentity | null
|
2026-02-25 17:24:21 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface NoteSearchListProps<T extends NoteSearchResultItem> {
|
|
|
|
|
items: T[]
|
|
|
|
|
selectedIndex: number
|
|
|
|
|
getItemKey: (item: T, index: number) => string
|
|
|
|
|
onItemClick: (item: T, index: number) => void
|
|
|
|
|
onItemHover?: (index: number) => void
|
2026-05-11 16:37:06 +02:00
|
|
|
activateOnMouseDown?: boolean
|
2026-02-25 17:24:21 +01:00
|
|
|
emptyMessage?: string
|
|
|
|
|
className?: string
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 19:52:14 +02:00
|
|
|
interface NoteSearchListItemProps<T extends NoteSearchResultItem> {
|
|
|
|
|
item: T
|
|
|
|
|
index: number
|
|
|
|
|
selected: boolean
|
|
|
|
|
onItemClick: (item: T, index: number) => void
|
|
|
|
|
onItemHover?: (index: number) => void
|
2026-05-11 16:37:06 +02:00
|
|
|
activateOnMouseDown?: boolean
|
2026-05-07 19:52:14 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function NoteSearchListItem<T extends NoteSearchResultItem>({
|
|
|
|
|
item,
|
|
|
|
|
index,
|
|
|
|
|
selected,
|
|
|
|
|
onItemClick,
|
|
|
|
|
onItemHover,
|
2026-05-11 16:37:06 +02:00
|
|
|
activateOnMouseDown,
|
2026-05-07 19:52:14 +02:00
|
|
|
}: NoteSearchListItemProps<T>) {
|
2026-05-11 16:37:06 +02:00
|
|
|
const pressActivatedRef = useRef(false)
|
|
|
|
|
|
|
|
|
|
const activateFromPress = (event: MouseEvent<HTMLDivElement> | PointerEvent<HTMLDivElement>) => {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
if (!activateOnMouseDown) return
|
|
|
|
|
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
if (pressActivatedRef.current) return
|
|
|
|
|
|
|
|
|
|
pressActivatedRef.current = true
|
|
|
|
|
window.setTimeout(() => {
|
|
|
|
|
pressActivatedRef.current = false
|
|
|
|
|
}, 0)
|
|
|
|
|
onItemClick(item, index)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
|
|
|
|
|
if (activateOnMouseDown) {
|
|
|
|
|
event.preventDefault()
|
|
|
|
|
event.stopPropagation()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
onItemClick(item, index)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-07 19:52:14 +02:00
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'flex cursor-pointer items-center justify-between gap-2 px-3 py-1.5 transition-colors',
|
|
|
|
|
selected ? 'bg-accent' : 'hover:bg-secondary',
|
|
|
|
|
)}
|
2026-05-11 16:37:06 +02:00
|
|
|
onPointerDownCapture={activateFromPress}
|
|
|
|
|
onMouseDownCapture={activateFromPress}
|
|
|
|
|
onClick={handleClick}
|
2026-05-07 19:52:14 +02:00
|
|
|
onMouseEnter={() => onItemHover?.(index)}
|
|
|
|
|
>
|
|
|
|
|
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-sm text-foreground">
|
|
|
|
|
{item.TypeIcon && (
|
|
|
|
|
<item.TypeIcon
|
|
|
|
|
width={14}
|
|
|
|
|
height={14}
|
|
|
|
|
className="shrink-0"
|
|
|
|
|
style={item.typeColor ? { color: item.typeColor } : undefined}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
<NoteTitleIcon icon={item.noteIcon} size={14} testId="note-search-item-icon" />
|
|
|
|
|
<span className="truncate">{item.title}</span>
|
|
|
|
|
</span>
|
2026-05-11 16:37:06 +02:00
|
|
|
{(item.noteType || item.workspace) && (
|
|
|
|
|
<span className="ml-2 flex shrink-0 items-center gap-1.5">
|
|
|
|
|
{item.noteType && (
|
|
|
|
|
<Badge
|
|
|
|
|
variant="secondary"
|
|
|
|
|
className="shrink-0 text-[11px]"
|
|
|
|
|
style={item.typeColor ? { color: item.typeColor, backgroundColor: item.typeLightColor } : undefined}
|
|
|
|
|
>
|
|
|
|
|
{item.noteType}
|
|
|
|
|
</Badge>
|
|
|
|
|
)}
|
|
|
|
|
<WorkspaceInitialsBadge workspace={item.workspace} testId="note-search-workspace-badge" />
|
|
|
|
|
</span>
|
2026-05-07 19:52:14 +02:00
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-25 17:24:21 +01:00
|
|
|
export function NoteSearchList<T extends NoteSearchResultItem>({
|
|
|
|
|
items,
|
|
|
|
|
selectedIndex,
|
|
|
|
|
getItemKey,
|
|
|
|
|
onItemClick,
|
|
|
|
|
onItemHover,
|
2026-05-11 16:37:06 +02:00
|
|
|
activateOnMouseDown,
|
2026-02-25 17:24:21 +01:00
|
|
|
emptyMessage = 'No results',
|
|
|
|
|
className,
|
|
|
|
|
}: NoteSearchListProps<T>) {
|
|
|
|
|
const listRef = useRef<HTMLDivElement>(null)
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-05-07 19:52:14 +02:00
|
|
|
scrollSelectedHTMLChildIntoView(listRef.current, selectedIndex)
|
2026-02-25 17:24:21 +01:00
|
|
|
}, [selectedIndex])
|
|
|
|
|
|
|
|
|
|
if (items.length === 0) {
|
|
|
|
|
return (
|
|
|
|
|
<div ref={listRef} className={cn('py-1', className)}>
|
|
|
|
|
<div className="px-4 py-3 text-center text-[13px] text-muted-foreground">
|
|
|
|
|
{emptyMessage}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div ref={listRef} className={cn('py-1', className)}>
|
|
|
|
|
{items.map((item, i) => (
|
2026-05-07 19:52:14 +02:00
|
|
|
<NoteSearchListItem
|
2026-02-25 17:24:21 +01:00
|
|
|
key={getItemKey(item, i)}
|
2026-05-07 19:52:14 +02:00
|
|
|
item={item}
|
|
|
|
|
index={i}
|
|
|
|
|
selected={i === selectedIndex}
|
|
|
|
|
onItemClick={onItemClick}
|
|
|
|
|
onItemHover={onItemHover}
|
2026-05-11 16:37:06 +02:00
|
|
|
activateOnMouseDown={activateOnMouseDown}
|
2026-05-07 19:52:14 +02:00
|
|
|
/>
|
2026-02-25 17:24:21 +01:00
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
)
|
|
|
|
|
}
|