feat: enhance note list property chips

This commit is contained in:
Test
2026-04-08 21:28:28 +02:00
parent 01cabc21e1
commit 70e7e72120
4 changed files with 556 additions and 189 deletions

View File

@@ -1,9 +1,22 @@
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { NoteItem } from './NoteItem'
import { makeEntry } from '../test-utils/noteListTestUtils'
vi.mock('../utils/url', async () => {
const actual = await vi.importActual('../utils/url') as typeof import('../utils/url')
return { ...actual, openExternalUrl: vi.fn().mockResolvedValue(undefined) }
})
const { openExternalUrl } = await import('../utils/url') as typeof import('../utils/url') & {
openExternalUrl: ReturnType<typeof vi.fn>
}
describe('NoteItem', () => {
beforeEach(() => {
openExternalUrl.mockClear()
})
it('renders binary files as non-clickable muted rows', () => {
const binaryEntry = makeEntry({
path: '/vault/photo.png',
@@ -73,4 +86,113 @@ describe('NoteItem', () => {
expect(screen.queryByText('note.md')).not.toBeInTheDocument()
expect(screen.queryByTestId('change-status-icon')).not.toBeInTheDocument()
})
it('colors relationship chips by target type and opens the related note on Cmd+click only', () => {
const linkedProject = makeEntry({
path: '/vault/project/build-app.md',
filename: 'build-app.md',
title: 'Build App',
isA: 'Project',
})
const projectType = makeEntry({
path: '/vault/type/project.md',
filename: 'project.md',
title: 'Project',
isA: 'Type',
color: 'red',
icon: 'wrench',
})
const sourceEntry = makeEntry({
path: '/vault/note/source.md',
filename: 'source.md',
title: 'Source',
isA: 'Note',
relationships: { 'Belongs to': ['[[project/build-app]]'] },
})
const onClickNote = vi.fn()
render(
<NoteItem
entry={sourceEntry}
isSelected={false}
typeEntryMap={{ Project: projectType }}
allEntries={[sourceEntry, linkedProject, projectType]}
displayPropsOverride={['Belongs to']}
onClickNote={onClickNote}
/>,
)
const chip = screen.getByTestId('property-chip-belongs-to-0')
expect(chip).toHaveTextContent('Build App')
expect(chip.className).toContain('cursor-pointer')
expect(chip).toHaveStyle({ color: 'var(--accent-red)', backgroundColor: 'var(--accent-red-light)' })
fireEvent.click(chip)
expect(onClickNote).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
expect(onClickNote).toHaveBeenCalledWith(linkedProject, expect.objectContaining({ metaKey: true }))
})
it('opens URL chips on Cmd+click only and keeps regular clicks inert', () => {
const entry = makeEntry({
path: '/vault/note/source.md',
filename: 'source.md',
title: 'Source',
properties: { URL: 'https://example.com/docs' },
})
const onClickNote = vi.fn()
render(
<NoteItem
entry={entry}
isSelected={false}
typeEntryMap={{}}
displayPropsOverride={['URL']}
onClickNote={onClickNote}
/>,
)
const chip = screen.getByTestId('property-chip-url-0')
expect(chip).toHaveTextContent('example.com')
expect(chip.className).toContain('cursor-pointer')
expect(chip).toHaveStyle({ color: 'var(--accent-blue)', backgroundColor: 'var(--accent-blue-light)' })
fireEvent.click(chip)
expect(openExternalUrl).not.toHaveBeenCalled()
expect(onClickNote).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
expect(openExternalUrl).toHaveBeenCalledWith('https://example.com/docs')
expect(onClickNote).not.toHaveBeenCalled()
})
it('renders broken relationship chips as neutral and non-interactive', () => {
const entry = makeEntry({
path: '/vault/note/source.md',
filename: 'source.md',
title: 'Source',
relationships: { Related: ['[[missing/note]]'] },
})
const onClickNote = vi.fn()
render(
<NoteItem
entry={entry}
isSelected={false}
typeEntryMap={{}}
allEntries={[entry]}
displayPropsOverride={['Related']}
onClickNote={onClickNote}
/>,
)
const chip = screen.getByTestId('property-chip-related-0')
expect(chip).toHaveTextContent('Note')
expect(chip.className).not.toContain('cursor-pointer')
fireEvent.click(chip, { metaKey: true })
expect(onClickNote).not.toHaveBeenCalled()
expect(openExternalUrl).not.toHaveBeenCalled()
})
})

View File

@@ -1,4 +1,4 @@
import { createElement, useMemo, useState, type ComponentType, type SVGAttributes } from 'react'
import { useMemo, type ComponentType, type SVGAttributes } from 'react'
import type { VaultEntry, NoteStatus } from '../types'
import { cn } from '@/lib/utils'
import {
@@ -7,11 +7,10 @@ import {
File, FileDashed,
} from '@phosphor-icons/react'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { findIcon, resolveIcon } from '../utils/iconRegistry'
import { resolveIcon } from '../utils/iconRegistry'
import { relativeDate, getDisplayDate } from '../utils/noteListHelpers'
import { resolveNoteIcon } from '../utils/noteIcon'
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../utils/wikilink'
import { NoteTitleIcon } from './NoteTitleIcon'
import { PropertyChips } from './note-item/PropertyChips'
const TYPE_ICON_MAP: Record<string, ComponentType<SVGAttributes<SVGSVGElement>>> = {
Project: Wrench,
@@ -60,134 +59,6 @@ function StateBadge({ archived }: { archived: boolean }) {
return null
}
function formatChipValue(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null
const s = String(value)
// URL: show only hostname
try {
if (s.startsWith('http://') || s.startsWith('https://')) return new URL(s).hostname
} catch { /* not a URL */ }
return s.length > 40 ? s.slice(0, 37) + '…' : s
}
interface PropertyChipValue {
label: string
noteIcon: string | null
typeIcon: string | null
}
function resolveChipValues(
entry: VaultEntry,
propName: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] {
if (propName.toLowerCase() === 'status') {
const formatted = formatChipValue(entry.status)
return formatted ? [{ label: formatted, noteIcon: null, typeIcon: null }] : []
}
// Check relationships first (wikilink values)
const relKey = Object.keys(entry.relationships).find((k) => k.toLowerCase() === propName.toLowerCase())
if (relKey) {
return entry.relationships[relKey]
.map((ref) => {
const targetEntry = resolveEntry(allEntries, wikilinkTarget(ref))
const label = wikilinkDisplay(ref)
return label ? {
label,
noteIcon: targetEntry?.icon ?? null,
typeIcon: targetEntry?.isA ? typeEntryMap[targetEntry.isA]?.icon ?? null : null,
} : null
})
.filter((value): value is PropertyChipValue => value !== null)
}
// Check scalar properties
const propKey = Object.keys(entry.properties).find((k) => k.toLowerCase() === propName.toLowerCase())
if (!propKey) return []
const val = entry.properties[propKey]
if (Array.isArray(val)) {
return val
.map((v) => formatChipValue(v))
.filter((v): v is string => v !== null)
.map((label) => ({ label, noteIcon: null, typeIcon: null }))
}
const formatted = formatChipValue(val)
return formatted ? [{ label: formatted, noteIcon: null, typeIcon: null }] : []
}
function PropertyChipIcon({ noteIcon, typeIcon }: { noteIcon?: string | null; typeIcon?: string | null }) {
const [imageFailed, setImageFailed] = useState(false)
const resolvedNoteIcon = resolveNoteIcon(noteIcon)
const TypeIcon = findIcon(typeIcon)
if (resolvedNoteIcon.kind === 'emoji') {
return (
<span aria-hidden="true" className="inline-flex shrink-0 items-center justify-center leading-none" style={{ fontSize: 11, lineHeight: 1 }}>
{resolvedNoteIcon.value}
</span>
)
}
if (resolvedNoteIcon.kind === 'phosphor') {
return <resolvedNoteIcon.Icon aria-hidden="true" width={11} height={11} className="shrink-0" />
}
if (resolvedNoteIcon.kind === 'image' && !imageFailed) {
return (
<img
src={resolvedNoteIcon.src}
alt=""
aria-hidden="true"
className="h-[11px] w-[11px] shrink-0 rounded-sm object-cover"
onError={() => setImageFailed(true)}
/>
)
}
if (!TypeIcon) return null
return createElement(TypeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
}
function PropertyChips({
entry,
displayProps,
allEntries,
typeEntryMap,
}: {
entry: VaultEntry
displayProps: string[]
allEntries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
}) {
const chips = useMemo(() => {
const result: { key: string; values: PropertyChipValue[] }[] = []
for (const prop of displayProps) {
const values = resolveChipValues(entry, prop, allEntries, typeEntryMap)
if (values.length > 0) result.push({ key: prop, values })
}
return result
}, [entry, displayProps, allEntries, typeEntryMap])
if (chips.length === 0) return null
return (
<div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips">
{chips.map(({ key, values }) =>
values.map((v, i) => (
<span
key={`${key}-${i}`}
className="inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground"
>
<PropertyChipIcon noteIcon={v.noteIcon} typeIcon={v.typeIcon} />
<span className="truncate whitespace-nowrap">{v.label}</span>
</span>
))
)}
</div>
)
}
const CHANGE_STATUS_DISPLAY: Record<string, { label: string; color: string; symbol: string }> = {
modified: { label: 'Modified', color: 'var(--accent-orange, #f59e0b)', symbol: '·' },
added: { label: 'Added', color: 'var(--accent-green, #22c55e)', symbol: '+' },
@@ -210,6 +81,117 @@ function ChangeStatusIcon({ status }: { status: string }) {
)
}
function noteItemClassName({
isBinary,
isSelected,
isMultiSelected,
isHighlighted,
}: {
isBinary: boolean
isSelected: boolean
isMultiSelected: boolean
isHighlighted: boolean
}) {
return cn(
'relative border-b border-[var(--border)] transition-colors',
isBinary ? 'cursor-default opacity-50' : 'cursor-pointer',
isSelected && !isMultiSelected && !isBinary && 'border-l-[3px]',
!isSelected && !isMultiSelected && !isBinary && 'hover:bg-muted',
isHighlighted && !isSelected && !isMultiSelected && !isBinary && 'bg-muted',
)
}
function ChangeStatusContent({
entry,
changeStatus,
isSelected,
isDeletedChange,
}: {
entry: VaultEntry
changeStatus: NonNullable<NoteItemProps['changeStatus']>
isSelected: boolean
isDeletedChange: boolean
}) {
return (
<>
<ChangeStatusIcon status={changeStatus} />
<div className="pr-5">
<div
className={cn(
'truncate text-[13px] font-mono',
isSelected ? 'font-semibold' : 'font-normal',
isDeletedChange && 'text-muted-foreground line-through opacity-70',
)}
style={{ fontSize: 12 }}
>
{entry.filename}
</div>
</div>
</>
)
}
function StandardNoteContent({
entry,
isBinary,
noteStatus,
isSelected,
typeColor,
displayProps,
allEntries,
typeEntryMap,
onClickNote,
}: {
entry: VaultEntry
isBinary: boolean
noteStatus: NoteStatus
isSelected: boolean
typeColor: string
displayProps: string[]
allEntries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onClickNote: NoteItemProps['onClickNote']
}) {
const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown'
const te = typeEntryMap[entry.isA ?? '']
const TypeIcon = useMemo(() => {
if (isNonMarkdown) return getFileKindIcon(entry.fileKind)
return getTypeIcon(entry.isA, te?.icon)
}, [entry.fileKind, entry.isA, isNonMarkdown, te?.icon])
return (
<>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="pr-5">
<div className={cn('truncate text-[13px]', isBinary ? 'text-muted-foreground' : 'text-foreground', isSelected && !isBinary ? 'font-semibold' : 'font-medium')}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
{!isBinary && <StateBadge archived={entry.archived} />}
</div>
</div>
{entry.snippet && !isBinary && (
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
)}
{!isBinary && displayProps.length > 0 && (
<PropertyChips
entry={entry}
displayProps={displayProps}
allEntries={allEntries}
typeEntryMap={typeEntryMap}
onOpenNote={onClickNote}
/>
)}
{!isBinary && (
<div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
)}
</>
)
}
function noteItemStyle(isSelected: boolean, isMultiSelected: boolean, typeColor: string, typeLightColor: string): React.CSSProperties {
const base: React.CSSProperties = { padding: isSelected && !isMultiSelected ? '14px 16px 14px 13px' : '14px 16px' }
if (isMultiSelected) base.backgroundColor = 'color-mix(in srgb, var(--accent-blue) 10%, transparent)'
@@ -228,7 +210,7 @@ function resolveDisplayProps(entry: VaultEntry, typeEntryMap: Record<string, Vau
return typeEntryMap[entry.isA ?? '']?.listPropertiesDisplay ?? []
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, allEntries, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: {
type NoteItemProps = {
entry: VaultEntry
isSelected: boolean
isMultiSelected?: boolean
@@ -242,32 +224,34 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
onClickNote: (entry: VaultEntry, e: React.MouseEvent) => void
onPrefetch?: (path: string) => void
onContextMenu?: (entry: VaultEntry, e: React.MouseEvent) => void
}) {
}
function createNoteItemClickHandler(
entry: VaultEntry,
isBinary: boolean,
onClickNote: NoteItemProps['onClickNote'],
) {
if (isBinary) {
return (event: React.MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}
}
return (event: React.MouseEvent) => onClickNote(entry, event)
}
export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlighted = false, noteStatus = 'clean', changeStatus, typeEntryMap, allEntries, displayPropsOverride, onClickNote, onPrefetch, onContextMenu }: NoteItemProps) {
const isBinary = entry.fileKind === 'binary'
const isNonMarkdown = !!entry.fileKind && entry.fileKind !== 'markdown'
const isDeletedChange = changeStatus === 'deleted'
const te = typeEntryMap[entry.isA ?? '']
const displayProps = resolveDisplayProps(entry, typeEntryMap, displayPropsOverride)
const typeColor = isBinary ? 'var(--muted-foreground)' : getTypeColor(entry.isA ?? 'Note', te?.color)
const typeLightColor = getTypeLightColor(entry.isA ?? 'Note', te?.color)
const TypeIcon = useMemo(() => {
if (isNonMarkdown) return getFileKindIcon(entry.fileKind)
return getTypeIcon(entry.isA, te?.icon)
}, [entry.isA, te?.icon, entry.fileKind, isNonMarkdown])
const handleClick = isBinary
? (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation() }
: (e: React.MouseEvent) => onClickNote(entry, e)
const handleClick = createNoteItemClickHandler(entry, isBinary, onClickNote)
return (
<div
className={cn(
"relative border-b border-[var(--border)] transition-colors",
isBinary ? "cursor-default opacity-50" : "cursor-pointer",
isSelected && !isMultiSelected && !isBinary && "border-l-[3px]",
!isSelected && !isMultiSelected && !isBinary && "hover:bg-muted",
isHighlighted && !isSelected && !isMultiSelected && !isBinary && "bg-muted"
)}
className={noteItemClassName({ isBinary, isSelected, isMultiSelected, isHighlighted })}
style={isBinary ? { padding: '14px 16px' } : noteItemStyle(isSelected, isMultiSelected, typeColor, typeLightColor)}
onClick={handleClick}
onContextMenu={onContextMenu ? (e) => onContextMenu(entry, e) : undefined}
@@ -279,45 +263,24 @@ export function NoteItem({ entry, isSelected, isMultiSelected = false, isHighlig
title={isBinary ? 'Cannot open this file type' : undefined}
>
{changeStatus ? (
<>
<ChangeStatusIcon status={changeStatus} />
<div className="pr-5">
<div
className={cn(
"truncate text-[13px] font-mono",
isSelected ? "font-semibold" : "font-normal",
isDeletedChange && "text-muted-foreground line-through opacity-70",
)}
style={{ fontSize: 12 }}
>
{entry.filename}
</div>
</div>
</>
<ChangeStatusContent
entry={entry}
changeStatus={changeStatus}
isSelected={isSelected}
isDeletedChange={isDeletedChange}
/>
) : (
<>
{/* eslint-disable-next-line react-hooks/static-components -- icon lookup from static map, no internal state */}
<TypeIcon width={14} height={14} className="absolute right-3 top-2.5" style={{ color: typeColor }} data-testid="type-icon" />
<div className="pr-5">
<div className={cn("truncate text-[13px]", isBinary ? "text-muted-foreground" : "text-foreground", isSelected && !isBinary ? "font-semibold" : "font-medium")}>
{noteStatus !== 'clean' && !isBinary && <StatusDot noteStatus={noteStatus} />}
<NoteTitleIcon icon={entry.icon} size={15} className="mr-1" testId="note-title-icon" />
{entry.title}
{!isBinary && <StateBadge archived={entry.archived} />}
</div>
</div>
{entry.snippet && !isBinary && (
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" data-testid="note-snippet" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
{entry.snippet}
</div>
)}
{!isBinary && displayProps.length > 0 && (
<PropertyChips entry={entry} displayProps={displayProps} allEntries={allEntries ?? [entry]} typeEntryMap={typeEntryMap} />
)}
{!isBinary && (
<div className="mt-0.5 text-[10px] text-muted-foreground">{relativeDate(getDisplayDate(entry))}</div>
)}
</>
<StandardNoteContent
entry={entry}
isBinary={isBinary}
noteStatus={noteStatus}
isSelected={isSelected}
typeColor={typeColor}
displayProps={displayProps}
allEntries={allEntries ?? [entry]}
typeEntryMap={typeEntryMap}
onClickNote={onClickNote}
/>
)}
</div>
)

View File

@@ -293,6 +293,41 @@ describe('NoteList rendering', () => {
expect(screen.getByText('Luca')).toBeInTheDocument()
expect(screen.queryByText('High')).not.toBeInTheDocument()
})
it('Cmd+clicks relationship chips through the note list without triggering the row click', () => {
const projectType = makeTypeDefinition('Project')
const taskType = makeTypeDefinition('Task', ['Belongs to'])
const projectEntry = makeEntry({
path: '/vault/project/build-app.md',
filename: 'build-app.md',
title: 'Build App',
isA: 'Project',
createdAt: 1700000000,
})
const taskEntry = makeEntry({
path: '/vault/task/write-tests.md',
filename: 'write-tests.md',
title: 'Write tests',
isA: 'Task',
relationships: { 'Belongs to': ['[[project/build-app]]'] },
createdAt: 1700000001,
})
const { onReplaceActiveTab, onSelectNote } = renderNoteList({
entries: [projectType, taskType, projectEntry, taskEntry],
selection: { kind: 'sectionGroup', type: 'Task' },
})
const chip = screen.getByTestId('property-chip-belongs-to-0')
fireEvent.click(chip)
expect(onReplaceActiveTab).not.toHaveBeenCalled()
expect(onSelectNote).not.toHaveBeenCalled()
fireEvent.click(chip, { metaKey: true })
expect(onSelectNote).toHaveBeenCalledWith(projectEntry)
expect(onReplaceActiveTab).not.toHaveBeenCalled()
})
})
describe('NoteList click behavior', () => {

View File

@@ -0,0 +1,247 @@
import { createElement, useMemo, useState, type CSSProperties, type MouseEvent } from 'react'
import { Link } from '@phosphor-icons/react'
import { cn } from '@/lib/utils'
import type { VaultEntry } from '../../types'
import { findIcon } from '../../utils/iconRegistry'
import { resolveNoteIcon } from '../../utils/noteIcon'
import { getTypeColor, getTypeLightColor } from '../../utils/typeColors'
import { isUrlValue, normalizeUrl, openExternalUrl } from '../../utils/url'
import { resolveEntry, wikilinkDisplay, wikilinkTarget } from '../../utils/wikilink'
interface PropertyChipValue {
label: string
noteIcon: string | null
typeIcon: string | null
style?: CSSProperties
action?: { kind: 'note'; entry: VaultEntry } | { kind: 'url'; url: string }
tone: 'neutral' | 'relationship' | 'url'
}
const URL_CHIP_STYLE: CSSProperties = {
backgroundColor: 'var(--accent-blue-light)',
color: 'var(--accent-blue)',
}
function toChipTestId(propName: string, index: number): string {
const slug = propName.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
return `property-chip-${slug || 'value'}-${index}`
}
function normalizeOpenableUrl(value: string): string | null {
if (!isUrlValue(value)) return null
const normalized = normalizeUrl(value)
try {
const url = new URL(normalized)
return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null
} catch {
return null
}
}
function formatChipLabel(value: unknown): string | null {
if (value === null || value === undefined || value === '') return null
const raw = String(value)
const openableUrl = normalizeOpenableUrl(raw)
if (openableUrl) return new URL(openableUrl).hostname
return raw.length > 40 ? `${raw.slice(0, 37)}` : raw
}
function resolveRelationshipChipStyle(targetEntry: VaultEntry, typeEntryMap: Record<string, VaultEntry>): CSSProperties | undefined {
const typeEntry = targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
const color = getTypeColor(targetEntry.isA, typeEntry?.color)
const backgroundColor = getTypeLightColor(targetEntry.isA, typeEntry?.color)
if (color === 'var(--muted-foreground)' && backgroundColor === 'var(--muted)') return undefined
return { color, backgroundColor }
}
function resolveRelationshipChip(
ref: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue | null {
const label = wikilinkDisplay(ref)
if (!label) return null
const targetEntry = resolveEntry(allEntries, wikilinkTarget(ref))
if (!targetEntry) {
return {
label,
noteIcon: null,
typeIcon: null,
tone: 'neutral',
}
}
const typeEntry = targetEntry.isA ? (typeEntryMap[targetEntry.isA] ?? typeEntryMap[targetEntry.isA.toLowerCase()]) : undefined
return {
label,
noteIcon: targetEntry.icon ?? null,
typeIcon: targetEntry.isA ? typeEntry?.icon ?? null : null,
style: resolveRelationshipChipStyle(targetEntry, typeEntryMap),
action: { kind: 'note', entry: targetEntry },
tone: 'relationship',
}
}
function resolveScalarChip(value: unknown): PropertyChipValue | null {
const label = formatChipLabel(value)
if (!label) return null
const openableUrl = typeof value === 'string' ? normalizeOpenableUrl(value) : null
if (openableUrl) {
return {
label,
noteIcon: null,
typeIcon: null,
style: URL_CHIP_STYLE,
action: { kind: 'url', url: openableUrl },
tone: 'url',
}
}
return {
label,
noteIcon: null,
typeIcon: null,
tone: 'neutral',
}
}
function resolvePropertyChipValues(
entry: VaultEntry,
propName: string,
allEntries: VaultEntry[],
typeEntryMap: Record<string, VaultEntry>,
): PropertyChipValue[] {
if (propName.toLowerCase() === 'status') {
const statusChip = resolveScalarChip(entry.status)
return statusChip ? [statusChip] : []
}
const relationshipKey = Object.keys(entry.relationships).find((key) => key.toLowerCase() === propName.toLowerCase())
if (relationshipKey) {
return entry.relationships[relationshipKey]
.map((ref) => resolveRelationshipChip(ref, allEntries, typeEntryMap))
.filter((chip): chip is PropertyChipValue => chip !== null)
}
const propertyKey = Object.keys(entry.properties).find((key) => key.toLowerCase() === propName.toLowerCase())
if (!propertyKey) return []
const rawValue = entry.properties[propertyKey]
const values = Array.isArray(rawValue) ? rawValue : [rawValue]
return values
.map((value) => resolveScalarChip(value))
.filter((chip): chip is PropertyChipValue => chip !== null)
}
function PropertyChipIcon({
noteIcon,
typeIcon,
tone,
}: {
noteIcon?: string | null
typeIcon?: string | null
tone: PropertyChipValue['tone']
}) {
const [imageFailed, setImageFailed] = useState(false)
if (tone === 'url') {
return <Link aria-hidden="true" width={11} height={11} className="shrink-0" />
}
const resolvedNoteIcon = resolveNoteIcon(noteIcon)
const TypeIcon = findIcon(typeIcon)
if (resolvedNoteIcon.kind === 'emoji') {
return (
<span aria-hidden="true" className="inline-flex shrink-0 items-center justify-center leading-none" style={{ fontSize: 11, lineHeight: 1 }}>
{resolvedNoteIcon.value}
</span>
)
}
if (resolvedNoteIcon.kind === 'phosphor') {
return <resolvedNoteIcon.Icon aria-hidden="true" width={11} height={11} className="shrink-0" />
}
if (resolvedNoteIcon.kind === 'image' && !imageFailed) {
return (
<img
src={resolvedNoteIcon.src}
alt=""
aria-hidden="true"
className="h-[11px] w-[11px] shrink-0 rounded-sm object-cover"
onError={() => setImageFailed(true)}
/>
)
}
if (!TypeIcon) return null
return createElement(TypeIcon, { 'aria-hidden': true, width: 11, height: 11, className: 'shrink-0' })
}
async function handleChipClick(
event: MouseEvent<HTMLSpanElement>,
chip: PropertyChipValue,
onOpenNote: (entry: VaultEntry, event: MouseEvent) => void,
) {
event.preventDefault()
event.stopPropagation()
if (!event.metaKey || !chip.action) return
if (chip.action.kind === 'note') {
onOpenNote(chip.action.entry, event)
return
}
await openExternalUrl(chip.action.url).catch(() => {})
}
export function PropertyChips({
entry,
displayProps,
allEntries,
typeEntryMap,
onOpenNote,
}: {
entry: VaultEntry
displayProps: string[]
allEntries: VaultEntry[]
typeEntryMap: Record<string, VaultEntry>
onOpenNote: (entry: VaultEntry, event: MouseEvent) => void
}) {
const chips = useMemo(() => {
const result: { key: string; values: PropertyChipValue[] }[] = []
for (const prop of displayProps) {
const values = resolvePropertyChipValues(entry, prop, allEntries, typeEntryMap)
if (values.length > 0) result.push({ key: prop, values })
}
return result
}, [allEntries, displayProps, entry, typeEntryMap])
if (chips.length === 0) return null
return (
<div className="mt-1 flex flex-wrap gap-1" data-testid="property-chips">
{chips.map(({ key, values }) =>
values.map((chip, index) => (
<span
key={`${key}-${index}`}
className={cn(
'inline-flex max-w-full items-center gap-1 rounded-md bg-muted px-1.5 py-0.5 text-[10px] text-muted-foreground',
chip.action && 'cursor-pointer',
)}
style={chip.style}
onClick={(event) => { void handleChipClick(event, chip, onOpenNote) }}
data-testid={toChipTestId(key, index)}
>
<PropertyChipIcon noteIcon={chip.noteIcon} typeIcon={chip.typeIcon} tone={chip.tone} />
<span className="truncate whitespace-nowrap">{chip.label}</span>
</span>
))
)}
</div>
)
}