Compare commits
6 Commits
v0.2026040
...
v0.2026040
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
02a6622e64 | ||
|
|
8979b71bda | ||
|
|
13edf894fb | ||
|
|
70e7e72120 | ||
|
|
01cabc21e1 | ||
|
|
5ceed87d36 |
@@ -1,2 +1,2 @@
|
||||
HOTSPOT_THRESHOLD=9.68
|
||||
AVERAGE_THRESHOLD=9.3
|
||||
AVERAGE_THRESHOLD=9.32
|
||||
|
||||
@@ -27,6 +27,12 @@ describe('FilterBuilder value inputs', () => {
|
||||
)
|
||||
}
|
||||
|
||||
function openFieldCombobox() {
|
||||
const input = screen.getByTestId('filter-field-combobox-input')
|
||||
fireEvent.focus(input)
|
||||
return input
|
||||
}
|
||||
|
||||
it('renders a plain text input for text operators', () => {
|
||||
renderBuilder()
|
||||
expect(screen.getByTestId('filter-value-input')).toBeInTheDocument()
|
||||
@@ -129,7 +135,74 @@ describe('FilterBuilder value inputs', () => {
|
||||
expect(screen.getByTestId('date-picker-trigger')).toHaveAttribute('title', 'Mar 28, 2026')
|
||||
})
|
||||
|
||||
it('shows body field in field dropdown separated from property fields', () => {
|
||||
it('filters the field combobox as the user types', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
|
||||
onChange={vi.fn()}
|
||||
availableFields={['type', 'status', 'title', 'Owner']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = openFieldCombobox()
|
||||
fireEvent.change(input, { target: { value: 'tit' } })
|
||||
|
||||
expect(screen.getByTestId('filter-field-option-title')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('filter-field-option-status')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('filter-field-option-Owner')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('supports keyboard navigation and Enter selection in the field combobox', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'title', op: 'contains', value: '' }] }}
|
||||
onChange={onChange}
|
||||
availableFields={['title', 'status', 'Owner']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = openFieldCombobox()
|
||||
fireEvent.change(input, { target: { value: 'sta' } })
|
||||
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
||||
fireEvent.keyDown(input, { key: 'Enter' })
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
all: [{ field: 'status', op: 'contains', value: '' }],
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('shows an empty state when no field matches the search text', () => {
|
||||
renderBuilder()
|
||||
|
||||
const input = openFieldCombobox()
|
||||
fireEvent.change(input, { target: { value: 'zzz' } })
|
||||
|
||||
expect(screen.getByTestId('filter-field-combobox-empty')).toHaveTextContent('No results')
|
||||
})
|
||||
|
||||
it('reopens the field combobox with the selected field prefilled and all options visible', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'status', op: 'contains', value: '' }] }}
|
||||
onChange={vi.fn()}
|
||||
availableFields={['type', 'status', 'title']}
|
||||
/>,
|
||||
)
|
||||
|
||||
const input = openFieldCombobox()
|
||||
|
||||
expect(input).toHaveValue('status')
|
||||
expect(screen.getByTestId('filter-field-option-type')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-field-option-status')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('filter-field-option-title')).toBeInTheDocument()
|
||||
|
||||
fireEvent.keyDown(input, { key: 'Escape' })
|
||||
expect(screen.queryByTestId('filter-field-combobox-options')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows body field in the searchable field combobox', () => {
|
||||
render(
|
||||
<FilterBuilder
|
||||
group={{ all: [{ field: 'body', op: 'contains', value: 'test' }] }}
|
||||
@@ -138,6 +211,8 @@ describe('FilterBuilder value inputs', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByText('body')).toBeInTheDocument()
|
||||
openFieldCombobox()
|
||||
|
||||
expect(screen.getByTestId('filter-field-option-body')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { Plus, X, CalendarBlank, WarningCircle } from '@phosphor-icons/react'
|
||||
import { format } from 'date-fns'
|
||||
import { Plus, X, WarningCircle } from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { FilterCondition, FilterOp, FilterGroup, FilterNode } from '../types'
|
||||
import { parseDateFilterInput } from '../utils/filterDates'
|
||||
import { FilterFieldCombobox } from './FilterFieldCombobox'
|
||||
import { DateValueInput } from './filter-builder/DateValueInput'
|
||||
|
||||
const OPERATORS: { value: FilterOp; label: string }[] = [
|
||||
{ value: 'equals', label: 'equals' },
|
||||
@@ -58,42 +56,6 @@ function setGroupChildren(mode: 'all' | 'any', children: FilterNode[]): FilterGr
|
||||
return mode === 'all' ? { all: children } : { any: children }
|
||||
}
|
||||
|
||||
const CONTENT_FIELDS = new Set(['body'])
|
||||
|
||||
function FieldSelect({ value, fields, onChange }: {
|
||||
value: string
|
||||
fields: string[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
const isCustom = value !== '' && !fields.includes(value)
|
||||
const propertyFields = fields.filter(f => !CONTENT_FIELDS.has(f))
|
||||
const contentFields = fields.filter(f => CONTENT_FIELDS.has(f))
|
||||
return (
|
||||
<Select value={value} onValueChange={onChange}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="h-8 min-w-[100px] flex-1 gap-1 border-input bg-background px-2 text-sm shadow-none"
|
||||
>
|
||||
<SelectValue placeholder="field" />
|
||||
</SelectTrigger>
|
||||
<SelectContent position="popper">
|
||||
{isCustom && <SelectItem value={value}>{value}</SelectItem>}
|
||||
{propertyFields.map((f) => (
|
||||
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||
))}
|
||||
{contentFields.length > 0 && (
|
||||
<>
|
||||
<SelectSeparator />
|
||||
{contentFields.map((f) => (
|
||||
<SelectItem key={f} value={f}>{f}</SelectItem>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
function OperatorSelect({ value, onChange }: {
|
||||
value: FilterOp
|
||||
onChange: (v: FilterOp) => void
|
||||
@@ -116,43 +78,6 @@ function OperatorSelect({ value, onChange }: {
|
||||
)
|
||||
}
|
||||
|
||||
function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const selected = value ? parseDateFilterInput(value) ?? undefined : undefined
|
||||
return (
|
||||
<div className="flex flex-1 min-w-0 items-center gap-1">
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder='YYYY-MM-DD or "10 days ago"'
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
data-testid="date-value-input"
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="date-picker-trigger"
|
||||
className="h-8 w-8 shrink-0 px-0"
|
||||
title={selected ? format(selected, 'MMM d, yyyy') : 'Pick a date'}
|
||||
aria-label={selected ? `Open date picker (${format(selected, 'MMM d, yyyy')})` : 'Open date picker'}
|
||||
>
|
||||
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TextValueInput({ value, onChange, regexEnabled, regexSupported, invalidRegex, onToggleRegex }: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
@@ -217,7 +142,7 @@ function FilterRow({ condition, fields, onUpdate, onRemove }: {
|
||||
const invalidRegex = regexSupported && hasInvalidRegex(String(condition.value ?? ''), regexEnabled)
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FieldSelect
|
||||
<FilterFieldCombobox
|
||||
value={condition.field}
|
||||
fields={fields}
|
||||
onChange={(v) => onUpdate({ ...condition, field: v })}
|
||||
|
||||
314
src/components/FilterFieldCombobox.tsx
Normal file
314
src/components/FilterFieldCombobox.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import { CaretUpDown } from '@phosphor-icons/react'
|
||||
import { useEffect, useId, useMemo, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type RefObject } from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { FilterFieldOptionsList } from './filter-builder/FilterFieldOptionsList'
|
||||
|
||||
const CONTENT_FIELDS = new Set(['body'])
|
||||
|
||||
interface FilterFieldComboboxProps {
|
||||
value: string
|
||||
fields: string[]
|
||||
onChange: (value: string) => void
|
||||
}
|
||||
|
||||
interface FieldGroup {
|
||||
key: 'property' | 'content'
|
||||
label: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
function normalizeFieldQuery(query: string): string {
|
||||
return query.trim().toLowerCase()
|
||||
}
|
||||
|
||||
function buildFieldGroups(fields: string[], currentValue: string, query: string): FieldGroup[] {
|
||||
const allFields = currentValue !== '' && !fields.includes(currentValue)
|
||||
? [currentValue, ...fields]
|
||||
: fields
|
||||
const normalized = normalizeFieldQuery(query)
|
||||
const matches = (field: string) => normalized === '' || field.toLowerCase().includes(normalized)
|
||||
const propertyOptions = allFields.filter((field) => !CONTENT_FIELDS.has(field) && matches(field))
|
||||
const contentOptions = allFields.filter((field) => CONTENT_FIELDS.has(field) && matches(field))
|
||||
const groups: FieldGroup[] = []
|
||||
|
||||
if (propertyOptions.length > 0) groups.push({ key: 'property', label: 'Properties', options: propertyOptions })
|
||||
if (contentOptions.length > 0) groups.push({ key: 'content', label: 'Content', options: contentOptions })
|
||||
|
||||
return groups
|
||||
}
|
||||
|
||||
function flattenGroups(groups: FieldGroup[]): string[] {
|
||||
return groups.flatMap((group) => group.options)
|
||||
}
|
||||
|
||||
function initialHighlightIndex(options: string[], currentValue: string): number {
|
||||
if (options.length === 0) return -1
|
||||
const currentIndex = options.indexOf(currentValue)
|
||||
return currentIndex >= 0 ? currentIndex : 0
|
||||
}
|
||||
|
||||
function stepHighlightedIndex(current: number, optionCount: number, direction: 'next' | 'previous'): number {
|
||||
if (current < 0) return direction === 'next' ? 0 : optionCount - 1
|
||||
if (direction === 'next') return (current + 1) % optionCount
|
||||
return (current - 1 + optionCount) % optionCount
|
||||
}
|
||||
|
||||
function handleFilterFieldKeyDown({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
highlightedIndex,
|
||||
openCombobox,
|
||||
setHighlightedIndex,
|
||||
selectOption,
|
||||
closeCombobox,
|
||||
}: {
|
||||
event: KeyboardEvent<HTMLInputElement>
|
||||
open: boolean
|
||||
options: string[]
|
||||
highlightedIndex: number
|
||||
openCombobox: () => void
|
||||
setHighlightedIndex: (updater: number | ((current: number) => number)) => void
|
||||
selectOption: (value: string) => void
|
||||
closeCombobox: () => void
|
||||
}) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'next'))
|
||||
return
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
if (!open) {
|
||||
openCombobox()
|
||||
return
|
||||
}
|
||||
if (options.length === 0) return
|
||||
setHighlightedIndex((current) => stepHighlightedIndex(current, options.length, 'previous'))
|
||||
return
|
||||
case 'Enter':
|
||||
if (!open || highlightedIndex < 0 || options[highlightedIndex] === undefined) return
|
||||
event.preventDefault()
|
||||
selectOption(options[highlightedIndex])
|
||||
return
|
||||
case 'Escape':
|
||||
if (!open) return
|
||||
event.preventDefault()
|
||||
closeCombobox()
|
||||
return
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function FilterFieldInput({
|
||||
inputRef,
|
||||
open,
|
||||
query,
|
||||
value,
|
||||
listboxId,
|
||||
highlightedIndex,
|
||||
onFocus,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
}: {
|
||||
inputRef: RefObject<HTMLInputElement | null>
|
||||
open: boolean
|
||||
query: string
|
||||
value: string
|
||||
listboxId: string
|
||||
highlightedIndex: number
|
||||
onFocus: () => void
|
||||
onChange: (event: ChangeEvent<HTMLInputElement>) => void
|
||||
onKeyDown: (event: KeyboardEvent<HTMLInputElement>) => void
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={open ? query : value}
|
||||
onFocus={onFocus}
|
||||
onChange={onChange}
|
||||
onKeyDown={onKeyDown}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-controls={listboxId}
|
||||
aria-expanded={open}
|
||||
aria-activedescendant={highlightedIndex >= 0 ? `${listboxId}-option-${highlightedIndex}` : undefined}
|
||||
className="h-8 pr-7 text-sm"
|
||||
data-testid="filter-field-combobox-input"
|
||||
/>
|
||||
<CaretUpDown
|
||||
size={14}
|
||||
className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function FilterFieldPopoverPanel({
|
||||
open,
|
||||
contentWidth,
|
||||
listboxId,
|
||||
fieldGroups,
|
||||
options,
|
||||
highlightedIndex,
|
||||
onHighlight,
|
||||
onSelect,
|
||||
}: {
|
||||
open: boolean
|
||||
contentWidth: number
|
||||
listboxId: string
|
||||
fieldGroups: FieldGroup[]
|
||||
options: string[]
|
||||
highlightedIndex: number
|
||||
onHighlight: (index: number) => void
|
||||
onSelect: (value: string) => void
|
||||
}) {
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
className="max-h-60 overflow-y-auto p-1"
|
||||
style={{ width: contentWidth }}
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
onCloseAutoFocus={(event) => event.preventDefault()}
|
||||
data-testid="filter-field-combobox-popover"
|
||||
>
|
||||
<div id={listboxId} role="listbox" data-testid="filter-field-combobox-options">
|
||||
<FilterFieldOptionsList
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={onHighlight}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function FilterFieldCombobox({ value, fields, onChange }: FilterFieldComboboxProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState(value)
|
||||
const [hasTyped, setHasTyped] = useState(false)
|
||||
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
||||
const [contentWidth, setContentWidth] = useState<number>(220)
|
||||
const rootRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
const listboxId = useId()
|
||||
const effectiveQuery = hasTyped ? query : ''
|
||||
const fieldGroups = useMemo(() => buildFieldGroups(fields, value, effectiveQuery), [fields, value, effectiveQuery])
|
||||
const options = useMemo(() => flattenGroups(fieldGroups), [fieldGroups])
|
||||
|
||||
const resetToCurrentValue = () => {
|
||||
setQuery(value)
|
||||
setHasTyped(false)
|
||||
setHighlightedIndex(initialHighlightIndex(flattenGroups(buildFieldGroups(fields, value, '')), value))
|
||||
}
|
||||
|
||||
const openCombobox = () => {
|
||||
resetToCurrentValue()
|
||||
setOpen(true)
|
||||
requestAnimationFrame(() => inputRef.current?.select())
|
||||
}
|
||||
|
||||
const closeCombobox = () => {
|
||||
setOpen(false)
|
||||
resetToCurrentValue()
|
||||
}
|
||||
|
||||
const selectOption = (nextValue: string) => {
|
||||
onChange(nextValue)
|
||||
setQuery(nextValue)
|
||||
setHasTyped(false)
|
||||
setHighlightedIndex(-1)
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
const updateWidth = () => {
|
||||
const nextWidth = rootRef.current?.getBoundingClientRect().width ?? 220
|
||||
setContentWidth(Math.max(nextWidth, 220))
|
||||
}
|
||||
|
||||
updateWidth()
|
||||
window.addEventListener('resize', updateWidth)
|
||||
return () => window.removeEventListener('resize', updateWidth)
|
||||
}, [open])
|
||||
|
||||
const handleBlur = (event: FocusEvent<HTMLDivElement>) => {
|
||||
if (rootRef.current?.contains(event.relatedTarget as Node | null)) return
|
||||
closeCombobox()
|
||||
}
|
||||
|
||||
const handleInputChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const nextQuery = event.target.value
|
||||
const nextGroups = buildFieldGroups(fields, value, nextQuery)
|
||||
const nextOptions = flattenGroups(nextGroups)
|
||||
setOpen(true)
|
||||
setQuery(nextQuery)
|
||||
setHasTyped(true)
|
||||
setHighlightedIndex(nextOptions.length > 0 ? 0 : -1)
|
||||
}
|
||||
|
||||
const handleInputKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
|
||||
handleFilterFieldKeyDown({
|
||||
event,
|
||||
open,
|
||||
options,
|
||||
highlightedIndex,
|
||||
openCombobox,
|
||||
setHighlightedIndex,
|
||||
selectOption,
|
||||
closeCombobox,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover open={open}>
|
||||
<PopoverAnchor asChild>
|
||||
<div
|
||||
ref={rootRef}
|
||||
className="relative flex-1 min-w-[160px]"
|
||||
onBlur={handleBlur}
|
||||
data-testid="filter-field-combobox"
|
||||
>
|
||||
<FilterFieldInput
|
||||
inputRef={inputRef}
|
||||
open={open}
|
||||
query={query}
|
||||
value={value}
|
||||
listboxId={listboxId}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onFocus={openCombobox}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
/>
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<FilterFieldPopoverPanel
|
||||
open={open}
|
||||
contentWidth={contentWidth}
|
||||
listboxId={listboxId}
|
||||
fieldGroups={fieldGroups}
|
||||
options={options}
|
||||
highlightedIndex={highlightedIndex}
|
||||
onHighlight={setHighlightedIndex}
|
||||
onSelect={selectOption}
|
||||
/>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
59
src/components/filter-builder/DateValueInput.test.tsx
Normal file
59
src/components/filter-builder/DateValueInput.test.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { useState } from 'react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { DateValueInput } from './DateValueInput'
|
||||
|
||||
describe('DateValueInput', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
function renderControlledDateValueInput(initialValue = '') {
|
||||
function ControlledDateValueInput() {
|
||||
const [value, setValue] = useState(initialValue)
|
||||
return <DateValueInput value={value} onChange={setValue} />
|
||||
}
|
||||
|
||||
return render(<ControlledDateValueInput />)
|
||||
}
|
||||
|
||||
it('shows a debounced resolved-date preview while focused', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(new Date('2026-04-08T12:00:00Z'))
|
||||
|
||||
renderControlledDateValueInput()
|
||||
|
||||
const input = screen.getByTestId('date-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, { target: { value: '10 days ago' } })
|
||||
|
||||
expect(screen.queryByTestId('date-value-preview')).not.toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(250)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-preview')).toHaveTextContent('Resolves to March 29, 2026')
|
||||
})
|
||||
|
||||
it('shows a neutral hint for unrecognized input and hides the preview on blur', async () => {
|
||||
vi.useFakeTimers()
|
||||
|
||||
renderControlledDateValueInput()
|
||||
|
||||
const input = screen.getByTestId('date-value-input')
|
||||
fireEvent.focus(input)
|
||||
fireEvent.change(input, { target: { value: 'eventually maybe' } })
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(250)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('date-value-preview-unrecognized')).toHaveTextContent('Not recognized')
|
||||
|
||||
fireEvent.blur(input)
|
||||
|
||||
expect(screen.queryByTestId('date-value-preview')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('date-value-preview-unrecognized')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
78
src/components/filter-builder/DateValueInput.tsx
Normal file
78
src/components/filter-builder/DateValueInput.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { CalendarBlank } from '@phosphor-icons/react'
|
||||
import { format } from 'date-fns'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { parseDateFilterInput } from '@/utils/filterDates'
|
||||
|
||||
const DATE_PREVIEW_DEBOUNCE_MS = 250
|
||||
|
||||
export function DateValueInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
const selected = value ? parseDateFilterInput(value) ?? undefined : undefined
|
||||
const [showPreview, setShowPreview] = useState(false)
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const timeoutId = window.setTimeout(() => setDebouncedValue(value), DATE_PREVIEW_DEBOUNCE_MS)
|
||||
return () => window.clearTimeout(timeoutId)
|
||||
}, [value])
|
||||
|
||||
const previewValue = showPreview ? debouncedValue.trim() : ''
|
||||
const resolvedPreview = previewValue ? parseDateFilterInput(previewValue) : null
|
||||
const previewLabel = resolvedPreview
|
||||
? format(resolvedPreview, 'MMMM d, yyyy')
|
||||
: previewValue
|
||||
? 'Not recognized'
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 min-w-0 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1">
|
||||
<Input
|
||||
className="h-8 flex-1 min-w-0 text-sm"
|
||||
placeholder='YYYY-MM-DD or "10 days ago"'
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setShowPreview(true)
|
||||
onChange(e.target.value)
|
||||
}}
|
||||
onFocus={() => setShowPreview(true)}
|
||||
onBlur={() => setShowPreview(false)}
|
||||
data-testid="date-value-input"
|
||||
/>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="date-picker-trigger"
|
||||
className="h-8 w-8 shrink-0 px-0"
|
||||
title={selected ? format(selected, 'MMM d, yyyy') : 'Pick a date'}
|
||||
aria-label={selected ? `Open date picker (${format(selected, 'MMM d, yyyy')})` : 'Open date picker'}
|
||||
>
|
||||
<CalendarBlank size={14} className="shrink-0 text-muted-foreground" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={(day) => onChange(day ? format(day, 'yyyy-MM-dd') : '')}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
{previewLabel && (
|
||||
<div
|
||||
className="pl-1 text-[11px] text-muted-foreground"
|
||||
data-testid={resolvedPreview ? 'date-value-preview' : 'date-value-preview-unrecognized'}
|
||||
>
|
||||
{resolvedPreview ? `Resolves to ${previewLabel}` : previewLabel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
22
src/components/filter-builder/FilterFieldCombobox.test.tsx
Normal file
22
src/components/filter-builder/FilterFieldCombobox.test.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { FilterFieldCombobox } from '../FilterFieldCombobox'
|
||||
|
||||
describe('FilterFieldCombobox', () => {
|
||||
it('renders its option list outside the clipped field container', () => {
|
||||
render(
|
||||
<div className="h-12 overflow-hidden">
|
||||
<FilterFieldCombobox value="status" fields={['status', 'title', 'Owner']} onChange={vi.fn()} />
|
||||
</div>,
|
||||
)
|
||||
|
||||
const root = screen.getByTestId('filter-field-combobox')
|
||||
const input = screen.getByTestId('filter-field-combobox-input')
|
||||
|
||||
fireEvent.focus(input)
|
||||
|
||||
const listbox = screen.getByRole('listbox')
|
||||
expect(listbox).toBeInTheDocument()
|
||||
expect(root.contains(listbox)).toBe(false)
|
||||
})
|
||||
})
|
||||
69
src/components/filter-builder/FilterFieldOptionsList.tsx
Normal file
69
src/components/filter-builder/FilterFieldOptionsList.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface FieldGroup {
|
||||
key: 'property' | 'content'
|
||||
label: string
|
||||
options: string[]
|
||||
}
|
||||
|
||||
function optionTestId(field: string): string {
|
||||
return `filter-field-option-${field.replace(/[^a-z0-9_-]+/gi, '-')}`
|
||||
}
|
||||
|
||||
export function FilterFieldOptionsList({
|
||||
listboxId,
|
||||
fieldGroups,
|
||||
options,
|
||||
highlightedIndex,
|
||||
onHighlight,
|
||||
onSelect,
|
||||
}: {
|
||||
listboxId: string
|
||||
fieldGroups: FieldGroup[]
|
||||
options: string[]
|
||||
highlightedIndex: number
|
||||
onHighlight: (index: number) => void
|
||||
onSelect: (field: string) => void
|
||||
}) {
|
||||
if (options.length === 0) {
|
||||
return (
|
||||
<div className="px-2 py-6 text-center text-sm text-muted-foreground" data-testid="filter-field-combobox-empty">
|
||||
No results
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{fieldGroups.map((group, groupIndex) => (
|
||||
<div key={group.key}>
|
||||
{groupIndex > 0 && <div className="my-1 border-t border-border" />}
|
||||
{group.options.map((field) => {
|
||||
const optionIndex = options.indexOf(field)
|
||||
return (
|
||||
<button
|
||||
key={field}
|
||||
id={`${listboxId}-option-${optionIndex}`}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={optionIndex === highlightedIndex}
|
||||
className={cn(
|
||||
'flex w-full items-center rounded px-2 py-1.5 text-left text-sm',
|
||||
optionIndex === highlightedIndex
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onMouseEnter={() => onHighlight(optionIndex)}
|
||||
onClick={() => onSelect(field)}
|
||||
data-testid={optionTestId(field)}
|
||||
>
|
||||
<span className="truncate">{field}</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
247
src/components/note-item/PropertyChips.tsx
Normal file
247
src/components/note-item/PropertyChips.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -42,12 +42,16 @@ function isTextInputFocused(): boolean {
|
||||
return tag === 'INPUT' || tag === 'TEXTAREA'
|
||||
}
|
||||
|
||||
function isCmdOnly(e: KeyboardEvent): boolean {
|
||||
function isCommandOrCtrlOnly(e: KeyboardEvent): boolean {
|
||||
return (e.metaKey || e.ctrlKey) && e.altKey === false
|
||||
}
|
||||
|
||||
function isCmdShiftOnly(e: KeyboardEvent): boolean {
|
||||
return isCmdOnly(e) && e.shiftKey
|
||||
function isCommandOrCtrlShiftOnly(e: KeyboardEvent): boolean {
|
||||
return isCommandOrCtrlOnly(e) && e.shiftKey
|
||||
}
|
||||
|
||||
function isCommandShiftOnly(e: KeyboardEvent): boolean {
|
||||
return e.metaKey && e.ctrlKey === false && e.altKey === false && e.shiftKey
|
||||
}
|
||||
|
||||
function withActiveTab(
|
||||
@@ -86,7 +90,6 @@ export function createCommandKeyMap(actions: KeyboardActions): ShortcutMap {
|
||||
|
||||
export function createShiftCommandKeyMap(actions: KeyboardActions): ShortcutMap {
|
||||
return {
|
||||
l: () => actions.onToggleAIChat?.(),
|
||||
f: () => {
|
||||
trackEvent('search_used')
|
||||
actions.onSearch()
|
||||
@@ -97,7 +100,7 @@ export function createShiftCommandKeyMap(actions: KeyboardActions): ShortcutMap
|
||||
}
|
||||
|
||||
export function handleViewModeKey(e: KeyboardEvent, onSetViewMode: (mode: ViewMode) => void): boolean {
|
||||
if (isCmdOnly(e) === false || e.shiftKey) return false
|
||||
if (isCommandOrCtrlOnly(e) === false || e.shiftKey) return false
|
||||
const mode = VIEW_MODE_KEYS[e.key]
|
||||
if (mode === undefined) return false
|
||||
e.preventDefault()
|
||||
@@ -106,7 +109,7 @@ export function handleViewModeKey(e: KeyboardEvent, onSetViewMode: (mode: ViewMo
|
||||
}
|
||||
|
||||
export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean {
|
||||
if (isCmdOnly(e) === false || e.shiftKey) return false
|
||||
if (isCommandOrCtrlOnly(e) === false || e.shiftKey) return false
|
||||
const handler = keyMap[e.key]
|
||||
if (handler === undefined) return false
|
||||
if (TEXT_EDITING_KEYS.has(e.key) && isTextInputFocused()) return false
|
||||
@@ -115,8 +118,15 @@ export function handleCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean
|
||||
return true
|
||||
}
|
||||
|
||||
export function handleAiPanelKey(e: KeyboardEvent, onToggleAIChat?: () => void): boolean {
|
||||
if (isCommandShiftOnly(e) === false || e.key.toLowerCase() !== 'l' || onToggleAIChat === undefined) return false
|
||||
e.preventDefault()
|
||||
onToggleAIChat()
|
||||
return true
|
||||
}
|
||||
|
||||
export function handleShiftCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): boolean {
|
||||
if (isCmdShiftOnly(e) === false) return false
|
||||
if (isCommandOrCtrlShiftOnly(e) === false) return false
|
||||
const handler = keyMap[e.key.toLowerCase()]
|
||||
if (handler === undefined) return false
|
||||
e.preventDefault()
|
||||
@@ -125,6 +135,7 @@ export function handleShiftCommandKey(e: KeyboardEvent, keyMap: ShortcutMap): bo
|
||||
}
|
||||
|
||||
export function handleAppKeyboardEvent(actions: KeyboardActions, event: KeyboardEvent) {
|
||||
if (handleAiPanelKey(event, actions.onToggleAIChat)) return
|
||||
const shiftKeyMap = createShiftCommandKeyMap(actions)
|
||||
if (handleShiftCommandKey(event, shiftKeyMap)) return
|
||||
if (handleViewModeKey(event, actions.onSetViewMode)) return
|
||||
|
||||
@@ -201,6 +201,14 @@ describe('useAppKeyboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('Ctrl+Shift+L does not trigger toggle AI chat', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
renderHook(() => useAppKeyboard({ ...actions, onToggleAIChat }))
|
||||
fireKey('l', { ctrlKey: true, shiftKey: true })
|
||||
expect(onToggleAIChat).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('Cmd+I does not trigger AI chat (reserved for italic)', () => {
|
||||
const actions = makeActions()
|
||||
const onToggleAIChat = vi.fn()
|
||||
|
||||
@@ -18,6 +18,12 @@ describe('filterDates', () => {
|
||||
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2026-03-28')
|
||||
})
|
||||
|
||||
it('parses numeric relative year phrases', () => {
|
||||
const reference = new Date('2026-04-08T12:00:00Z')
|
||||
const parsed = parseDateFilterInput('10 years ago', reference)
|
||||
expect(parsed && format(parsed, 'yyyy-MM-dd')).toBe('2016-04-08')
|
||||
})
|
||||
|
||||
it('parses word-based relative week phrases', () => {
|
||||
const reference = new Date('2026-04-07T12:00:00Z')
|
||||
const parsed = parseDateFilterInput('one week ago', reference)
|
||||
|
||||
Reference in New Issue
Block a user