diff --git a/src/components/Editor.css b/src/components/Editor.css index e102b82e..b300fcef 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -202,17 +202,31 @@ flex-shrink: 0; } -.title-section__add-icon { - /* "Add icon" button above the title when no emoji — Notion-style */ - padding-top: 24px; - margin-left: 8px; - min-height: 28px; +.title-section__heading { + position: relative; +} + +.title-section__inline-add-icon { + position: absolute; + left: 8px; + top: 0; + z-index: 1; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; +} + +.title-section:hover .title-section__inline-add-icon, +.title-section__inline-add-icon:focus-within { + opacity: 1; + pointer-events: auto; } .title-section__row { display: flex; flex-direction: row; align-items: flex-start; + gap: 10px; padding-top: 8px; margin-left: 8px; } @@ -235,6 +249,8 @@ /* --- Note Icon Area --- */ .note-icon-area { + display: flex; + align-items: flex-start; flex-shrink: 0; } @@ -251,16 +267,24 @@ } .note-icon-button--active { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; font-size: 36px; line-height: 1; transition: transform 0.1s; - margin-right: 10px; } .note-icon-button--active:hover:not(:disabled) { transform: scale(1.1); } +.note-icon-button--active :is(img, svg) { + display: block; +} + .note-icon-button--add { display: flex; align-items: center; diff --git a/src/components/IconEditableValue.test.tsx b/src/components/IconEditableValue.test.tsx new file mode 100644 index 00000000..c3b42e4a --- /dev/null +++ b/src/components/IconEditableValue.test.tsx @@ -0,0 +1,76 @@ +import { fireEvent, render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { IconEditableValue } from './IconEditableValue' + +function renderIconValue(overrides: Partial> = {}) { + const onSave = vi.fn() + const onCancel = vi.fn() + const onStartEdit = vi.fn() + + render( + , + ) + + return { onSave, onCancel, onStartEdit } +} + +describe('IconEditableValue', () => { + it('shows searchable icon results with previews while editing', () => { + renderIconValue() + + expect(screen.getByTestId('icon-editable-input')).toHaveAttribute('role', 'combobox') + expect(screen.getByTestId('icon-picker-results')).toBeInTheDocument() + expect(screen.getAllByRole('option').length).toBeGreaterThan(0) + }) + + it('selects the first matching icon on Enter for icon-name queries', () => { + const { onSave } = renderIconValue() + const input = screen.getByTestId('icon-editable-input') + + fireEvent.change(input, { target: { value: 'rocket' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onSave).toHaveBeenCalledWith('rocket') + }) + + it('supports keyboard navigation before selecting an icon', () => { + const { onSave } = renderIconValue() + const input = screen.getByTestId('icon-editable-input') + + fireEvent.change(input, { target: { value: 'file' } }) + const options = screen.getAllByRole('option') + + expect(options.length).toBeGreaterThan(1) + + fireEvent.keyDown(input, { key: 'ArrowDown' }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onSave).toHaveBeenCalledWith(options[1].textContent ?? '') + }) + + it('keeps manual URL values instead of forcing an icon suggestion', () => { + const { onSave } = renderIconValue() + const input = screen.getByTestId('icon-editable-input') + + fireEvent.change(input, { target: { value: 'https://example.com/icon.png' } }) + fireEvent.keyDown(input, { key: 'Enter' }) + + expect(onSave).toHaveBeenCalledWith('https://example.com/icon.png') + }) + + it('shows an empty state when no icon matches the query', () => { + renderIconValue() + const input = screen.getByTestId('icon-editable-input') + + fireEvent.change(input, { target: { value: 'totally-not-a-real-icon' } }) + + expect(screen.getByTestId('icon-picker-empty')).toHaveTextContent('No icons found') + }) +}) diff --git a/src/components/IconEditableValue.tsx b/src/components/IconEditableValue.tsx new file mode 100644 index 00000000..4d134879 --- /dev/null +++ b/src/components/IconEditableValue.tsx @@ -0,0 +1,181 @@ +import { useId, useMemo, useState } from 'react' +import { Input } from '@/components/ui/input' +import { cn } from '@/lib/utils' +import { isEmoji } from '../utils/emoji' +import { ICON_OPTIONS } from '../utils/iconRegistry' + +const MAX_ICON_RESULTS = 24 + +function isHttpUrl(value: string): boolean { + try { + const url = new URL(value) + return url.protocol === 'http:' || url.protocol === 'https:' + } catch { + return false + } +} + +function normalizeIconQuery(query: string): string { + return query.trim().toLowerCase() +} + +function matchesIconQuery(name: string, query: string): boolean { + if (!query) return true + + const normalized = normalizeIconQuery(query) + const spacedName = name.replace(/-/g, ' ') + const dashedQuery = normalized.replace(/\s+/g, '-') + + return name.includes(dashedQuery) || spacedName.includes(normalized) +} + +function filterIconOptions(query: string) { + return ICON_OPTIONS + .filter((option) => matchesIconQuery(option.name, query)) + .slice(0, MAX_ICON_RESULTS) +} + +function shouldSelectIconSuggestion(value: string, suggestionCount: number): boolean { + const trimmed = value.trim() + if (!trimmed) return false + if (isEmoji(trimmed) || isHttpUrl(trimmed)) return false + return suggestionCount > 0 +} + +interface IconEditableValueProps { + value: string + onSave: (newValue: string) => void + onCancel: () => void + isEditing: boolean + onStartEdit: () => void +} + +function IconEditableInput({ + value, + onSave, + onCancel, +}: Pick) { + const [editValue, setEditValue] = useState(value) + const [highlightedIndex, setHighlightedIndex] = useState(0) + const listboxId = useId() + const filteredIcons = useMemo(() => filterIconOptions(editValue), [editValue]) + + const commitTypedValue = () => onSave(editValue) + const selectIcon = (iconName: string) => { + setEditValue(iconName) + onSave(iconName) + } + + const handleKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + if (filteredIcons.length === 0) return + setHighlightedIndex((current) => (current + 1) % filteredIcons.length) + return + } + + if (event.key === 'ArrowUp') { + event.preventDefault() + if (filteredIcons.length === 0) return + setHighlightedIndex((current) => (current - 1 + filteredIcons.length) % filteredIcons.length) + return + } + + if (event.key === 'Enter') { + event.preventDefault() + if (shouldSelectIconSuggestion(editValue, filteredIcons.length)) { + selectIcon(filteredIcons[highlightedIndex]?.name ?? editValue) + return + } + commitTypedValue() + return + } + + if (event.key === 'Escape') { + event.preventDefault() + onCancel() + } + } + + return ( +
+ { + setEditValue(event.target.value) + setHighlightedIndex(0) + }} + onKeyDown={handleKeyDown} + onBlur={commitTypedValue} + autoFocus + role="combobox" + aria-autocomplete="list" + aria-controls={listboxId} + aria-expanded + aria-activedescendant={filteredIcons[highlightedIndex] ? `${listboxId}-option-${highlightedIndex}` : undefined} + placeholder="Emoji, icon name, or URL" + className="h-8 text-[12px]" + data-testid="icon-editable-input" + /> +
+ {filteredIcons.length === 0 ? ( +
+ No icons found +
+ ) : ( + filteredIcons.map(({ name, Icon }, index) => ( + + )) + )} +
+
+ ) +} + +export function IconEditableValue({ + value, + onSave, + onCancel, + isEditing, + onStartEdit, +}: IconEditableValueProps) { + if (isEditing) { + return + } + + return ( + + {value || '\u2014'} + + ) +} diff --git a/src/components/PropertyValueCells.tsx b/src/components/PropertyValueCells.tsx index 36d4d8a2..704ab789 100644 --- a/src/components/PropertyValueCells.tsx +++ b/src/components/PropertyValueCells.tsx @@ -19,6 +19,7 @@ import { getStatusStyle } from '../utils/statusStyles' import { TagsDropdown } from './TagsDropdown' import { getTagStyle } from '../utils/tagStyles' import { ColorEditableValue } from './ColorInput' +import { IconEditableValue } from './IconEditableValue' function parseDateValue(value: string): Date | undefined { const iso = toISODate(value) @@ -301,9 +302,52 @@ type SmartCellProps = { onSaveList: (key: string, items: string[]) => void; onUpdate?: (key: string, value: FrontmatterValue) => void } -function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses, vaultTags, onStartEdit, onSave, onSaveList, onUpdate }: SmartCellProps) { - const editProps = { value: String(value ?? ''), isEditing, onStartEdit: () => onStartEdit(propKey), onSave: (v: string) => onSave(propKey, v), onCancel: () => onStartEdit(null) } - const resolvedMode = displayMode === 'text' ? autoDetectFromValue(propKey, value) : displayMode +interface ScalarEditProps { + value: string + isEditing: boolean + onStartEdit: () => void + onSave: (nextValue: string) => void + onCancel: () => void +} + +function createScalarEditProps({ + propKey, + value, + isEditing, + onStartEdit, + onSave, +}: { + propKey: string + value: FrontmatterValue + isEditing: boolean + onStartEdit: (key: string | null) => void + onSave: (key: string, value: string) => void +}): ScalarEditProps { + return { + value: String(value ?? ''), + isEditing, + onStartEdit: () => onStartEdit(propKey), + onSave: (nextValue: string) => onSave(propKey, nextValue), + onCancel: () => onStartEdit(null), + } +} + +function renderScalarDisplayMode({ + propKey, + value, + isEditing, + resolvedMode, + vaultStatuses, + vaultTags, + onSave, + onSaveList, + onStartEdit, + onUpdate, + editProps, +}: SmartCellProps & { + resolvedMode: PropertyDisplayMode + editProps: ScalarEditProps +}) { switch (resolvedMode) { case 'status': return @@ -332,6 +376,24 @@ function ScalarValueCell({ propKey, value, displayMode, isEditing, vaultStatuses } } +function ScalarValueCell(props: SmartCellProps) { + const { propKey, value, displayMode, isEditing, onStartEdit, onSave } = props + const editProps = createScalarEditProps({ + propKey, + value, + isEditing, + onStartEdit, + onSave, + }) + + if (propKey.toLowerCase() === 'icon') { + return + } + + const resolvedMode = displayMode === 'text' ? autoDetectFromValue(propKey, value) : displayMode + return renderScalarDisplayMode({ ...props, resolvedMode, editProps }) +} + export function SmartPropertyValueCell(props: SmartCellProps) { const { propKey, value, displayMode, isEditing, vaultTags, onSaveList, onStartEdit } = props if (Array.isArray(value)) { diff --git a/src/components/editor-content/EditorContentLayout.test.tsx b/src/components/editor-content/EditorContentLayout.test.tsx new file mode 100644 index 00000000..f378218b --- /dev/null +++ b/src/components/editor-content/EditorContentLayout.test.tsx @@ -0,0 +1,120 @@ +import { createRef } from 'react' +import { render, screen } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { EditorContentLayout } from './EditorContentLayout' + +vi.mock('../BreadcrumbBar', () => ({ + BreadcrumbBar: () =>
, +})) + +vi.mock('../TitleField', () => ({ + TitleField: () =>
, +})) + +vi.mock('../NoteIcon', () => ({ + NoteIcon: ({ icon }: { icon: string | null }) => ( + icon + ?