fix: align property panel rows

This commit is contained in:
lucaronin
2026-04-16 11:53:29 +02:00
parent 53b3f5a14b
commit 711d49b3bb
5 changed files with 210 additions and 121 deletions

View File

@@ -1,4 +1,6 @@
import { Plus } from '@phosphor-icons/react'
import { useMemo, useCallback, useEffect, useState } from 'react'
import { Button } from '@/components/ui/button'
import type { VaultEntry } from '../types'
import type { FrontmatterValue } from './Inspector'
import type { ParsedFrontmatter } from '../utils/frontmatter'
@@ -9,7 +11,14 @@ import { TypeSelector } from './TypeSelector'
import { AddPropertyForm } from './AddPropertyForm'
import type { PropertyDisplayMode } from '../utils/propertyTypes'
import { FOCUS_NOTE_ICON_PROPERTY_EVENT } from './noteIconPropertyEvents'
import { PROPERTY_PANEL_GRID_STYLE, PROPERTY_PANEL_ROW_STYLE } from './propertyPanelLayout'
import {
PROPERTY_PANEL_GRID_STYLE,
PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME,
PROPERTY_PANEL_LABEL_CLASS_NAME,
PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME,
PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME,
PROPERTY_PANEL_ROW_STYLE,
} from './propertyPanelLayout'
import { humanizePropertyKey } from '../utils/propertyLabels'
import { canonicalSystemMetadataKey, hasSystemMetadataKey } from '../utils/systemMetadata'
@@ -21,8 +30,6 @@ export function containsWikilinks(value: FrontmatterValue): boolean {
}
const PROPERTY_ROW_CLASS_NAME = 'group/prop grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary'
const PROPERTY_LABEL_CLASS_NAME = 'flex max-w-full min-w-0 items-center gap-1 text-[12px] text-muted-foreground'
const SUGGESTED_PROPERTY_SLOT_CLASS_NAME = 'grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 rounded border-none bg-transparent px-1.5 text-left outline-none transition-colors hover:bg-muted focus:bg-muted focus:ring-1 focus:ring-primary cursor-pointer'
function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate, onDelete, onDisplayModeChange }: {
propKey: string; value: FrontmatterValue; editingKey: string | null
@@ -45,7 +52,7 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
return (
<div className={PROPERTY_ROW_CLASS_NAME} style={PROPERTY_PANEL_ROW_STYLE} tabIndex={0} onKeyDown={handleKeyDown} data-testid="editable-property">
<span className={PROPERTY_LABEL_CLASS_NAME}>
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
<DisplayModeSelector propKey={propKey} currentMode={displayMode} autoMode={autoMode} onSelect={onDisplayModeChange} />
<span className="min-w-0 flex-1 truncate">{humanizePropertyKey(propKey)}</span>
{onDelete && (
@@ -61,13 +68,27 @@ function PropertyRow({ propKey, value, editingKey, displayMode, autoMode, vaultS
function AddPropertyButton({ onClick, disabled }: { onClick: () => void; disabled: boolean }) {
return (
<button
className="mt-1 flex w-full cursor-pointer items-center gap-1 border-none bg-transparent px-1.5 text-[12px] text-muted-foreground opacity-50 transition-opacity hover:opacity-100 disabled:cursor-not-allowed"
onClick={onClick} disabled={disabled}
<Button
type="button"
variant="ghost"
size="sm"
className={PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME}
style={PROPERTY_PANEL_ROW_STYLE}
onClick={onClick}
disabled={disabled}
data-testid="add-property-row"
>
<span className="text-[12px] leading-none">+</span>
Add property
</button>
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
<span
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
data-testid="add-property-icon-slot"
>
<Plus className="size-3.5" aria-hidden="true" />
</span>
<span className="min-w-0 truncate">Add property</span>
</span>
<span aria-hidden="true" className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME} />
</Button>
)
}
@@ -97,20 +118,29 @@ function SuggestedPropertySlot({ label, displayMode, onAdd }: {
const SuggestedIcon = DISPLAY_MODE_ICONS[displayMode]
return (
<button
className={SUGGESTED_PROPERTY_SLOT_CLASS_NAME}
<Button
type="button"
variant="ghost"
size="sm"
className={PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME}
style={PROPERTY_PANEL_ROW_STYLE}
tabIndex={0}
onClick={onAdd}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAdd() } }}
data-testid="suggested-property"
>
<span className="flex min-w-0 max-w-full items-center gap-1 text-[12px] text-muted-foreground/50">
<SuggestedIcon className="size-3.5 shrink-0 text-muted-foreground/40" data-testid={`suggested-property-icon-${displayMode}`} />
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
<span
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
data-testid="suggested-property-icon-slot"
>
<SuggestedIcon
className="size-3.5 shrink-0 text-muted-foreground/40"
data-testid={`suggested-property-icon-${displayMode}`}
/>
</span>
<span className="min-w-0 truncate">{label}</span>
</span>
<span className="min-w-0 truncate text-[12px] text-muted-foreground/30">{'\u2014'}</span>
</button>
<span className={PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME}>{'\u2014'}</span>
</Button>
)
}
@@ -150,6 +180,46 @@ function useFocusNoteIconProperty({
}, [onAddProperty, setEditingKey, setPendingSuggestedKey])
}
function useSuggestedPropertyActions({
onAddProperty,
setEditingKey,
setPendingSuggestedKey,
}: {
onAddProperty?: (key: string, value: FrontmatterValue) => void
setEditingKey: (key: string | null) => void
setPendingSuggestedKey: (key: string | null) => void
}) {
const handleSuggestedAdd = useCallback((key: string) => {
if (!onAddProperty) return
setPendingSuggestedKey(key)
setEditingKey(key)
}, [onAddProperty, setEditingKey, setPendingSuggestedKey])
const handlePendingSuggestedEdit = useCallback((key: string | null) => {
setEditingKey(key)
if (key === null) setPendingSuggestedKey(null)
}, [setEditingKey, setPendingSuggestedKey])
const handleSaveSuggestedValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
setPendingSuggestedKey(null)
if (!onAddProperty) {
return
}
const trimmed = newValue.trim()
if (!trimmed) {
return
}
onAddProperty(key === 'icon' ? canonicalSystemMetadataKey(key) : key, trimmed)
}, [onAddProperty, setEditingKey, setPendingSuggestedKey])
return {
handlePendingSuggestedEdit,
handleSaveSuggestedValue,
handleSuggestedAdd,
}
}
export function DynamicPropertiesPanel({
entry, frontmatter, entries,
onUpdateProperty, onDeleteProperty, onAddProperty, onNavigate,
@@ -175,30 +245,15 @@ export function DynamicPropertiesPanel({
() => getMissingSuggestedProperties(Boolean(onAddProperty), existingKeys, pendingSuggestedKey),
[existingKeys, onAddProperty, pendingSuggestedKey],
)
const handleSuggestedAdd = useCallback((key: string) => {
if (!onAddProperty) return
setPendingSuggestedKey(key)
setEditingKey(key)
}, [onAddProperty, setEditingKey])
const handlePendingSuggestedEdit = useCallback((key: string | null) => {
setEditingKey(key)
if (key === null) setPendingSuggestedKey(null)
}, [setEditingKey])
const handleSaveSuggestedValue = useCallback((key: string, newValue: string) => {
setEditingKey(null)
setPendingSuggestedKey(null)
if (!onAddProperty) {
return
}
const trimmed = newValue.trim()
if (!trimmed) {
return
}
onAddProperty(key === 'icon' ? canonicalSystemMetadataKey(key) : key, trimmed)
}, [onAddProperty, setEditingKey])
const {
handlePendingSuggestedEdit,
handleSaveSuggestedValue,
handleSuggestedAdd,
} = useSuggestedPropertyActions({
onAddProperty,
setEditingKey,
setPendingSuggestedKey,
})
useFocusNoteIconProperty({ onAddProperty, setEditingKey, setPendingSuggestedKey })
@@ -244,11 +299,20 @@ export function DynamicPropertiesPanel({
onAdd={() => handleSuggestedAdd(key)}
/>
))}
{!showAddDialog && (
<AddPropertyButton
onClick={() => setShowAddDialog(true)}
disabled={!onAddProperty}
/>
)}
</div>
{showAddDialog
? <AddPropertyForm onAdd={handleAdd} onCancel={() => setShowAddDialog(false)} vaultStatuses={vaultStatuses} />
: <AddPropertyButton onClick={() => setShowAddDialog(true)} disabled={!onAddProperty} />
}
{showAddDialog && (
<AddPropertyForm
onAdd={handleAdd}
onCancel={() => setShowAddDialog(false)}
vaultStatuses={vaultStatuses}
/>
)}
</div>
)
}

View File

@@ -1,3 +1,4 @@
import type { ComponentProps } from 'react'
import { render, screen, fireEvent } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest'
import { Inspector } from './Inspector'
@@ -88,6 +89,20 @@ const defaultProps = {
onNavigate: () => {},
}
type InspectorProps = ComponentProps<typeof Inspector>
function renderInspector(overrides: Partial<InspectorProps> = {}) {
return render(<Inspector {...defaultProps} {...overrides} />)
}
function renderSelectedInspector(overrides: Partial<InspectorProps> = {}) {
return renderInspector({
entry: mockEntry,
content: mockContent,
...overrides,
})
}
describe('Inspector', () => {
it('renders expanded state with "no note selected"', () => {
render(<Inspector {...defaultProps} />)
@@ -133,7 +148,7 @@ describe('Inspector', () => {
it('shows "Add property" button as disabled placeholder', () => {
render(<Inspector {...defaultProps} entry={mockEntry} content={mockContent} />)
const btn = screen.getByText('Add property')
const btn = screen.getByRole('button', { name: 'Add property' })
expect(btn).toBeDisabled()
})
@@ -212,41 +227,22 @@ This is a test note with some words to count.
})
it('hides backlinks section when no notes reference the current note', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
entries={[mockEntry]}
/>
)
renderSelectedInspector({ entries: [mockEntry] })
expect(screen.queryByText('Backlinks')).not.toBeInTheDocument()
})
it('navigates when a backlink is clicked', () => {
const onNavigate = vi.fn()
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
entries={[mockEntry, referrerEntry]}
onNavigate={onNavigate}
/>
)
renderSelectedInspector({
entries: [mockEntry, referrerEntry],
onNavigate,
})
fireEvent.click(screen.getByText('Referrer Note'))
expect(onNavigate).toHaveBeenCalledWith('Referrer Note')
})
it('shows git history with commit hashes and messages', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
gitHistory={mockGitHistory}
/>
)
renderSelectedInspector({ gitHistory: mockGitHistory })
expect(screen.getByText('History')).toBeInTheDocument()
expect(screen.getByText('a1b2c3d')).toBeInTheDocument()
expect(screen.getByText('e4f5g6h')).toBeInTheDocument()
@@ -255,15 +251,10 @@ This is a test note with some words to count.
it('renders commit entries as clickable buttons', () => {
const onViewCommitDiff = vi.fn()
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
gitHistory={mockGitHistory}
onViewCommitDiff={onViewCommitDiff}
/>
)
renderSelectedInspector({
gitHistory: mockGitHistory,
onViewCommitDiff,
})
const hashBtn = screen.getByText('a1b2c3d')
const button = hashBtn.closest('button')!
expect(button.tagName).toBe('BUTTON')
@@ -272,25 +263,12 @@ This is a test note with some words to count.
})
it('hides history section when no commits', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
gitHistory={[]}
/>
)
renderSelectedInspector({ gitHistory: [] })
expect(screen.queryByText('History')).not.toBeInTheDocument()
})
it('shows separate Info section with read-only metadata', () => {
render(
<Inspector
{...defaultProps}
entry={mockEntry}
content={mockContent}
/>
)
renderSelectedInspector()
expect(screen.getByText('Info')).toBeInTheDocument()
expect(screen.getByText('Modified')).toBeInTheDocument()
expect(screen.getByText('Created')).toBeInTheDocument()
@@ -468,31 +446,23 @@ Status: Active
})
it('hides referenced-by section when no entries reference the current note', () => {
render(
<Inspector
{...defaultProps}
entry={targetEntry}
content={targetContent}
entries={[targetEntry]}
/>
)
renderInspector({
entry: targetEntry,
content: targetContent,
entries: [targetEntry],
})
expect(screen.queryByText('No references')).not.toBeInTheDocument()
expect(screen.queryByText('Referenced by')).not.toBeInTheDocument()
})
it('navigates when clicking a referenced-by entry', () => {
const onNavigate = vi.fn()
render(
<Inspector
{...defaultProps}
entry={targetEntry}
content={targetContent}
entries={[targetEntry, essayEntry]}
onNavigate={onNavigate}
/>
)
renderInspector({
entry: targetEntry,
content: targetContent,
entries: [targetEntry, essayEntry],
onNavigate,
})
fireEvent.click(screen.getByText('On Writing Well'))
expect(onNavigate).toHaveBeenCalledWith('On Writing Well')
})

View File

@@ -8,7 +8,11 @@ import { cn } from '@/lib/utils'
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
import { getTypeIcon } from './NoteItem'
import { PROPERTY_CHIP_STYLE } from './propertyChipStyles'
import { PROPERTY_PANEL_ROW_STYLE } from './propertyPanelLayout'
import {
PROPERTY_PANEL_LABEL_CLASS_NAME,
PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME,
PROPERTY_PANEL_ROW_STYLE,
} from './propertyPanelLayout'
const TYPE_NONE = '__none__'
const MIN_POPOVER_WIDTH = 220
@@ -94,9 +98,14 @@ function TypeSelectorValue({
function TypeRowLabel() {
return (
<span className="flex shrink-0 items-center gap-1 text-[12px] text-muted-foreground">
<StackSimple size={14} className="shrink-0" data-testid="type-row-icon" />
<span>Type</span>
<span className={PROPERTY_PANEL_LABEL_CLASS_NAME}>
<span
className={PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME}
data-testid="type-row-icon-slot"
>
<StackSimple size={14} className="shrink-0" data-testid="type-row-icon" />
</span>
<span className="min-w-0 truncate">Type</span>
</span>
)
}
@@ -104,7 +113,10 @@ function TypeRowLabel() {
function ReadOnlyType({ isA, customColorKey, onNavigate }: { isA?: string | null; customColorKey?: string | null; onNavigate?: (target: string) => void }) {
if (!isA) return null
return (
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" style={PROPERTY_PANEL_ROW_STYLE}>
<div
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 px-1.5"
style={PROPERTY_PANEL_ROW_STYLE}
>
<TypeRowLabel />
<div className="min-w-0">
{onNavigate ? (
@@ -261,7 +273,11 @@ function EditableTypeSelector({ isA, customColorKey, availableTypes, typeColorKe
}
return (
<div className="grid min-w-0 grid-cols-2 items-center gap-2 px-1.5" style={PROPERTY_PANEL_ROW_STYLE} data-testid="type-selector">
<div
className="grid min-h-7 min-w-0 grid-cols-2 items-center gap-2 px-1.5"
style={PROPERTY_PANEL_ROW_STYLE}
data-testid="type-selector"
>
<TypeRowLabel />
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverAnchor asChild>

View File

@@ -76,6 +76,37 @@ describe('property panel shared grid layout', () => {
})
})
it('keeps the add-property row on the shared grid', () => {
render(
<DynamicPropertiesPanel
entry={entry}
frontmatter={{}}
onAddProperty={vi.fn()}
/>
)
const row = screen.getByTestId('add-property-row')
expect(row.style.gridTemplateColumns).toBe('subgrid')
expect(row.style.gridColumn).toBe('1 / -1')
})
it('uses the same fixed icon slot size for type, suggested, and add-property rows', () => {
render(
<DynamicPropertiesPanel
entry={entry}
frontmatter={{}}
onAddProperty={vi.fn()}
onUpdateProperty={vi.fn()}
/>
)
expect(screen.getByTestId('type-row-icon-slot')).toHaveClass('size-5')
screen.getAllByTestId('suggested-property-icon-slot').forEach((slot) => {
expect(slot).toHaveClass('size-5')
})
expect(screen.getByTestId('add-property-icon-slot')).toHaveClass('size-5')
})
it('renders plain text values flush with the shared value column', () => {
render(
<DynamicPropertiesPanel

View File

@@ -8,3 +8,11 @@ export const PROPERTY_PANEL_ROW_STYLE = {
gridColumn: '1 / -1',
gridTemplateColumns: 'subgrid',
} satisfies CSSProperties
export const PROPERTY_PANEL_LABEL_CLASS_NAME = 'flex min-w-0 max-w-full items-center gap-1.5 text-[12px] text-muted-foreground'
export const PROPERTY_PANEL_LABEL_ICON_SLOT_CLASS_NAME = 'flex size-5 shrink-0 items-center justify-center'
export const PROPERTY_PANEL_INTERACTIVE_ROW_CLASS_NAME = 'grid h-auto min-h-7 w-full min-w-0 grid-cols-2 items-center gap-2 rounded px-1.5 text-left text-[12px] font-normal shadow-none outline-none transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:bg-muted focus-visible:ring-1 focus-visible:ring-primary'
export const PROPERTY_PANEL_PLACEHOLDER_VALUE_CLASS_NAME = 'min-w-0 truncate text-[12px] text-muted-foreground/30'