From 3a30e9bc419875cc266bf9705f22d49813e1c8f1 Mon Sep 17 00:00:00 2001 From: hugo Date: Sat, 21 Feb 2026 21:18:30 +0100 Subject: [PATCH] fix: submenu editing now finally works kinda like I want it --- .../MenuEditorView/MenuEditorView.css | 26 +++ .../MenuEditorView/MenuEditorView.tsx | 193 +++++------------- .../components/PageInput/PageInput.tsx | 180 ++++++++++++++++ src/renderer/components/PageInput/index.ts | 1 + src/renderer/components/TagInput/TagInput.tsx | 4 + .../components/MenuEditorView.test.tsx | 92 +++++++-- tests/renderer/components/PageInput.test.tsx | 82 ++++++++ 7 files changed, 418 insertions(+), 160 deletions(-) create mode 100644 src/renderer/components/PageInput/PageInput.tsx create mode 100644 src/renderer/components/PageInput/index.ts create mode 100644 tests/renderer/components/PageInput.test.tsx diff --git a/src/renderer/components/MenuEditorView/MenuEditorView.css b/src/renderer/components/MenuEditorView/MenuEditorView.css index 6c16b8b..f2ff14f 100644 --- a/src/renderer/components/MenuEditorView/MenuEditorView.css +++ b/src/renderer/components/MenuEditorView/MenuEditorView.css @@ -120,11 +120,18 @@ } .menu-editor-row-title { + flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.menu-editor-row-title.is-editing { + white-space: normal; + overflow: visible; + text-overflow: clip; +} + .menu-editor-inline-input { width: 100%; border: 1px solid var(--vscode-focusBorder); @@ -157,6 +164,12 @@ gap: 0.75rem; } +.menu-editor-inline-actions { + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + .menu-editor-inline-search-head strong { font-size: 0.8rem; } @@ -166,6 +179,19 @@ font-size: 0.75rem; } +.menu-editor-inline-action { + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 4px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + padding: 0.2rem 0.5rem; + cursor: pointer; +} + +.menu-editor-inline-action:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + .menu-editor-picker-backdrop { position: absolute; inset: 0; diff --git a/src/renderer/components/MenuEditorView/MenuEditorView.tsx b/src/renderer/components/MenuEditorView/MenuEditorView.tsx index 44c485b..61a6b6d 100644 --- a/src/renderer/components/MenuEditorView/MenuEditorView.tsx +++ b/src/renderer/components/MenuEditorView/MenuEditorView.tsx @@ -3,9 +3,10 @@ import { Tree } from 'react-arborist'; import { useI18n } from '../../i18n'; import { showToast } from '../Toast'; import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi'; +import { PageInput } from '../PageInput'; import { createAutoExpandController } from './menuAutoExpand'; import { resolveInsertTarget } from './menuInsertTarget'; -import { filterPagePosts, isPickerCloseKey, isPickerFocusShortcut } from './menuPagePicker'; +import { isPickerCloseKey } from './menuPagePicker'; import { applyTreeMove } from './menuTreeMove'; import './MenuEditorView.css'; @@ -171,11 +172,8 @@ export const MenuEditorView: React.FC = () => { const [isLoadingPages, setIsLoadingPages] = useState(false); const [pagePosts, setPagePosts] = useState([]); const [editingEntryId, setEditingEntryId] = useState(null); - const [editingText, setEditingText] = useState(''); - const [selectedPageId, setSelectedPageId] = useState(null); const [toolbarTooltip, setToolbarTooltip] = useState(''); const [recentParentInsertId, setRecentParentInsertId] = useState(null); - const entryInputRef = useRef(null); const recentInsertTimerRef = useRef | null>(null); const autoExpandController = useMemo(() => createAutoExpandController(450), []); @@ -212,13 +210,6 @@ export const MenuEditorView: React.FC = () => { } const onWindowKeyDown = (event: KeyboardEvent): void => { - if (isPickerFocusShortcut({ key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey })) { - event.preventDefault(); - entryInputRef.current?.focus(); - entryInputRef.current?.select(); - return; - } - if (isPickerCloseKey(event.key)) { event.preventDefault(); setItems((previous) => { @@ -229,24 +220,37 @@ export const MenuEditorView: React.FC = () => { return removeItemByPath(previous, path).next; }); setEditingEntryId(null); - setEditingText(''); - setSelectedPageId(null); } }; - window.addEventListener('keydown', onWindowKeyDown); + document.addEventListener('keydown', onWindowKeyDown); return () => { - window.removeEventListener('keydown', onWindowKeyDown); + document.removeEventListener('keydown', onWindowKeyDown); }; }, [editingEntryId]); useEffect(() => { - if (!editingEntryId) { + if (!editingEntryId || isLoadingPages) { return; } - entryInputRef.current?.focus(); - }, [editingEntryId]); + const focusInput = (): void => { + const input = document.querySelector('.menu-editor-row-title.is-editing .tag-input-field') as HTMLInputElement | null; + if (!input) { + return; + } + input.focus(); + input.select(); + }; + + const immediate = setTimeout(focusInput, 0); + const delayed = setTimeout(focusInput, 32); + + return () => { + clearTimeout(immediate); + clearTimeout(delayed); + }; + }, [editingEntryId, isLoadingPages]); const selectedPath = useMemo(() => { if (!selectedId) { @@ -255,13 +259,6 @@ export const MenuEditorView: React.FC = () => { return findPathById(items, selectedId); }, [items, selectedId]); - const filteredPagePosts = useMemo(() => { - if (!editingEntryId) { - return []; - } - return filterPagePosts(pagePosts, editingText); - }, [editingEntryId, pagePosts, editingText]); - const ensurePagePostsLoaded = async (): Promise => { if (pagePosts.length > 0) { return; @@ -280,59 +277,32 @@ export const MenuEditorView: React.FC = () => { } }; - const finalizeEntry = (): void => { + const setDraftAsSubmenu = (label: string): void => { if (!editingEntryId) { return; } - const selectedPage = selectedPageId ? pagePosts.find((post) => post.id === selectedPageId) : null; - const trimmed = editingText.trim(); + const trimmed = label.trim(); + const nextTitle = trimmed || tr('menuEditor.newSubmenu'); - if (selectedPage) { - setItems((previous) => mapItems(previous, (item) => { - if (item.id !== editingEntryId) { - return item; - } + setItems((previous) => mapItems(previous, (item) => { + if (item.id !== editingEntryId) { + return item; + } - return { - ...item, - title: selectedPage.title, - kind: 'page', - pageId: selectedPage.id, - pageSlug: selectedPage.slug, - }; - })); - } else if (trimmed) { - setItems((previous) => mapItems(previous, (item) => { - if (item.id !== editingEntryId) { - return item; - } - - return { - ...item, - title: trimmed, - kind: 'submenu', - pageId: undefined, - pageSlug: undefined, - }; - })); - } else { - setItems((previous) => { - const path = findPathById(previous, editingEntryId); - if (!path) { - return previous; - } - return removeItemByPath(previous, path).next; - }); - setSelectedId(null); - } + return { + ...item, + title: nextTitle, + kind: 'submenu', + pageId: undefined, + pageSlug: undefined, + }; + })); setEditingEntryId(null); - setEditingText(''); - setSelectedPageId(null); }; - const finalizeEntryWithPage = (post: PostData): void => { + const setDraftAsPage = (post: PostData): void => { if (!editingEntryId) { return; } @@ -352,8 +322,6 @@ export const MenuEditorView: React.FC = () => { })); setEditingEntryId(null); - setEditingText(''); - setSelectedPageId(null); }; const startCreateEntry = async (): Promise => { @@ -392,8 +360,6 @@ export const MenuEditorView: React.FC = () => { setSelectedId(newEntry.id); setEditingEntryId(newEntry.id); - setEditingText(''); - setSelectedPageId(null); }; const save = async (): Promise => { @@ -486,8 +452,6 @@ export const MenuEditorView: React.FC = () => { if (editingEntryId === selectedId) { setEditingEntryId(null); - setEditingText(''); - setSelectedPageId(null); } setSelectedId(null); }; @@ -622,79 +586,24 @@ export const MenuEditorView: React.FC = () => { {node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')} - {node.data.title} + + {editingEntryId === node.data.id ? ( + + ) : node.data.title} + )} )} - - {editingEntryId && ( -
-
- { - setEditingText(event.target.value); - setSelectedPageId(null); - }} - onKeyDown={(event) => { - if (event.key === 'Enter') { - event.preventDefault(); - finalizeEntry(); - } - - if (event.key === 'Escape') { - event.preventDefault(); - setItems((previous) => { - const path = findPathById(previous, editingEntryId); - if (!path) { - return previous; - } - return removeItemByPath(previous, path).next; - }); - setEditingEntryId(null); - setEditingText(''); - setSelectedPageId(null); - } - }} - placeholder={tr('menuEditor.newEntryPlaceholder')} - /> -
-
- {tr('menuEditor.pagePicker.title')} - {tr('menuEditor.createHint')} -
- {isLoadingPages ? ( -
{tr('menuEditor.pagePicker.loading')}
- ) : filteredPagePosts.length === 0 ? ( -
{tr('menuEditor.pagePicker.empty')}
- ) : ( -
- {filteredPagePosts.map((post) => ( - - ))} -
- )} -
- )} )} diff --git a/src/renderer/components/PageInput/PageInput.tsx b/src/renderer/components/PageInput/PageInput.tsx new file mode 100644 index 0000000..e990832 --- /dev/null +++ b/src/renderer/components/PageInput/PageInput.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import type { PostData } from '../../../main/shared/electronApi'; +import '../TagInput/TagInput.css'; + +interface PageInputProps { + pages: PostData[]; + onSelectPage: (page: PostData) => void; + onCreateSubmenu: (label: string) => void; + placeholder?: string; + createSubmenuLabel: string; + disabled?: boolean; + autoFocus?: boolean; +} + +export const PageInput: React.FC = ({ + pages, + onSelectPage, + onCreateSubmenu, + placeholder = '', + createSubmenuLabel, + disabled = false, + autoFocus = false, +}) => { + const [inputValue, setInputValue] = useState(''); + const [showSuggestions, setShowSuggestions] = useState(false); + const [selectedIndex, setSelectedIndex] = useState(-1); + const inputRef = useRef(null); + const containerRef = useRef(null); + + const suggestions = useMemo(() => { + if (!inputValue.trim()) { + return []; + } + + const query = inputValue.toLowerCase().trim(); + return pages + .filter((page) => page.title.toLowerCase().includes(query) || page.slug.toLowerCase().includes(query)) + .slice(0, 8); + }, [inputValue, pages]); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent): void => { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setShowSuggestions(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + useEffect(() => { + if (!autoFocus || disabled) { + return; + } + + const timer = setTimeout(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, 0); + + return () => clearTimeout(timer); + }, [autoFocus, disabled]); + + const selectPage = (page: PostData): void => { + onSelectPage(page); + setInputValue(''); + setShowSuggestions(false); + setSelectedIndex(-1); + }; + + const createSubmenu = (label: string): void => { + const trimmed = label.trim(); + if (!trimmed) { + return; + } + + onCreateSubmenu(trimmed); + setInputValue(''); + setShowSuggestions(false); + setSelectedIndex(-1); + inputRef.current?.focus(); + }; + + const exactMatchExists = inputValue.trim() + ? suggestions.some((item) => item.title.toLowerCase() === inputValue.trim().toLowerCase()) + : false; + + const showCreateOption = inputValue.trim() && !exactMatchExists; + + const handleKeyDown = (event: React.KeyboardEvent): void => { + if (event.key === 'ArrowDown') { + event.preventDefault(); + const maxIndex = suggestions.length + (showCreateOption ? 0 : -1); + setSelectedIndex((previous) => Math.min(previous + 1, maxIndex)); + return; + } + + if (event.key === 'ArrowUp') { + event.preventDefault(); + setSelectedIndex((previous) => Math.max(previous - 1, -1)); + return; + } + + if (event.key === 'Enter') { + event.preventDefault(); + if (selectedIndex >= 0 && selectedIndex < suggestions.length) { + selectPage(suggestions[selectedIndex]); + } else if (selectedIndex === suggestions.length && showCreateOption) { + createSubmenu(inputValue); + } else { + const exactMatch = pages.find((page) => page.title.toLowerCase() === inputValue.trim().toLowerCase()); + if (exactMatch) { + selectPage(exactMatch); + } else if (inputValue.trim()) { + createSubmenu(inputValue); + } + } + return; + } + + if (event.key === 'Escape') { + setShowSuggestions(false); + setInputValue(''); + } + }; + + return ( +
+
+ { + setInputValue(event.target.value); + setShowSuggestions(true); + }} + onInput={(event) => { + setInputValue((event.target as HTMLInputElement).value); + setShowSuggestions(true); + }} + onFocus={() => setShowSuggestions(true)} + onKeyDown={handleKeyDown} + placeholder={placeholder} + disabled={disabled} + autoComplete="off" + /> +
+ + {showSuggestions && (suggestions.length > 0 || showCreateOption) && ( +
+ {suggestions.map((page, index) => ( + + ))} + + {showCreateOption && ( + + )} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/PageInput/index.ts b/src/renderer/components/PageInput/index.ts new file mode 100644 index 0000000..bf263e4 --- /dev/null +++ b/src/renderer/components/PageInput/index.ts @@ -0,0 +1 @@ +export { PageInput } from './PageInput'; diff --git a/src/renderer/components/TagInput/TagInput.tsx b/src/renderer/components/TagInput/TagInput.tsx index 2cdb669..c3dc6a5 100644 --- a/src/renderer/components/TagInput/TagInput.tsx +++ b/src/renderer/components/TagInput/TagInput.tsx @@ -261,6 +261,10 @@ export const TagInput: React.FC = ({ setInputValue(e.target.value); setShowSuggestions(true); }} + onInput={(e) => { + setInputValue((e.target as HTMLInputElement).value); + setShowSuggestions(true); + }} onFocus={() => setShowSuggestions(true)} onKeyDown={handleKeyDown} placeholder={value.length === 0 ? placeholder : ''} diff --git a/tests/renderer/components/MenuEditorView.test.tsx b/tests/renderer/components/MenuEditorView.test.tsx index 2de53c2..9961634 100644 --- a/tests/renderer/components/MenuEditorView.test.tsx +++ b/tests/renderer/components/MenuEditorView.test.tsx @@ -8,9 +8,6 @@ describe('MenuEditorView entry editor', () => { beforeEach(() => { vi.clearAllMocks(); - (window as any).addEventListener = vi.fn(); - (window as any).removeEventListener = vi.fn(); - (window as any).electronAPI = { ...(window as any).electronAPI, menu: { @@ -46,26 +43,82 @@ describe('MenuEditorView entry editor', () => { }; }); - it('uses a standalone input editor and keeps focus while typing multiple characters', async () => { + it('uses the same selector control pattern as tag input for page selection', async () => { const { container } = render(); const addButton = await screen.findByRole('button', { name: /add entry/i }); fireEvent.click(addButton); const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); - expect(input.closest('.menu-editor-entry-editor')).not.toBeNull(); + expect(input.closest('.tag-input-wrapper')).not.toBeNull(); + expect(input.closest('.menu-editor-row')).not.toBeNull(); + expect(container.querySelector('.menu-editor-inline-search')).toBeNull(); + expect(container.querySelector('.menu-editor-picker-list')).toBeNull(); + expect(container.querySelector('.menu-editor-picker-item')).toBeNull(); - fireEvent.change(input, { target: { value: 'a' } }); - fireEvent.change(input, { target: { value: 'ab' } }); - fireEvent.change(input, { target: { value: 'abc' } }); - - expect((input as HTMLInputElement).value).toBe('abc'); - expect(document.activeElement).toBe(input); - - expect(container.querySelector('.menu-editor-row .menu-editor-inline-input')).toBeNull(); + fireEvent.input(input, { target: { value: 'ab' } }); + const suggestion = await screen.findByRole('button', { name: /^about$/i }); + expect(suggestion.className).toContain('tag-suggestion'); }); - it('renders all matching page results without UI capping', async () => { + it('focuses the new in-row page input immediately after creating an entry', async () => { + render(); + + const addButton = await screen.findByRole('button', { name: /add entry/i }); + fireEvent.click(addButton); + + const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); + expect(document.activeElement).toBe(input); + }); + + it('sets the current row as submenu from typed input instead of creating another entry', async () => { + render(); + + const addButton = await screen.findByRole('button', { name: /add entry/i }); + fireEvent.click(addButton); + + const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); + fireEvent.input(input, { target: { value: 'Products' } }); + + const createSubmenuOption = await screen.findByRole('button', { name: /add submenu/i }); + fireEvent.click(createSubmenuOption); + + expect(screen.queryByPlaceholderText(/type a page title or submenu label/i)).not.toBeInTheDocument(); + expect(screen.getByText('Products')).toBeInTheDocument(); + }); + + it('sets the current row to selected page from the suggestion list', async () => { + render(); + + const addButton = await screen.findByRole('button', { name: /add entry/i }); + fireEvent.click(addButton); + + const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); + fireEvent.input(input, { target: { value: 'about' } }); + + const pageSuggestion = await screen.findByRole('button', { name: /^about$/i }); + fireEvent.click(pageSuggestion); + + expect(screen.queryByPlaceholderText(/type a page title or submenu label/i)).not.toBeInTheDocument(); + expect(screen.getByText('About')).toBeInTheDocument(); + }); + + it('keeps focus while typing multiple characters', async () => { + const { container } = render(); + + const addButton = await screen.findByRole('button', { name: /add entry/i }); + fireEvent.click(addButton); + + const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); + fireEvent.input(input, { target: { value: 'a' } }); + fireEvent.input(input, { target: { value: 'ab' } }); + fireEvent.input(input, { target: { value: 'abc' } }); + + expect((input as HTMLInputElement).value).toBe('abc'); + expect(container.querySelector('.tag-input-field')).toBe(input); + }); + + it('caps matching page suggestions to the same limit as tag input', async () => { const pagePosts = Array.from({ length: 12 }).map((_, index) => ({ id: `page-${index + 1}`, projectId: 'project-1', @@ -87,10 +140,10 @@ describe('MenuEditorView entry editor', () => { fireEvent.click(addButton); const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); - fireEvent.change(input, { target: { value: 'page' } }); + fireEvent.input(input, { target: { value: 'page' } }); const options = await screen.findAllByRole('button', { name: /page\s+\d+/i }); - expect(options).toHaveLength(12); + expect(options).toHaveLength(8); }); it('shows standard outliner control buttons in the toolbar', async () => { @@ -115,14 +168,17 @@ describe('MenuEditorView entry editor', () => { expect(row).toHaveAttribute('data-drag-handle', 'true'); }); - it('finalizes entry as page on a double-click gesture', async () => { + it('finalizes entry as page on a single-click suggestion selection', async () => { render(); const addButton = await screen.findByRole('button', { name: /add entry/i }); fireEvent.click(addButton); + const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); + fireEvent.input(input, { target: { value: 'about' } }); + const pageOption = await screen.findByRole('button', { name: /about/i }); - fireEvent.doubleClick(pageOption); + fireEvent.click(pageOption); expect(screen.queryByPlaceholderText(/type a page title or submenu label/i)).not.toBeInTheDocument(); expect(screen.getByText('About')).toBeInTheDocument(); diff --git a/tests/renderer/components/PageInput.test.tsx b/tests/renderer/components/PageInput.test.tsx new file mode 100644 index 0000000..a6d07d6 --- /dev/null +++ b/tests/renderer/components/PageInput.test.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { describe, it, expect, vi } from 'vitest'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { PageInput } from '../../../src/renderer/components/PageInput/PageInput'; + +describe('PageInput', () => { + const pages = [ + { + id: 'page-1', + projectId: 'project-1', + title: 'About', + slug: 'about', + content: '', + status: 'published' as const, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tags: [], + categories: ['page'], + }, + { + id: 'page-2', + projectId: 'project-1', + title: 'Contact', + slug: 'contact', + content: '', + status: 'published' as const, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tags: [], + categories: ['page'], + }, + ]; + + it('selects a page suggestion', async () => { + const onSelectPage = vi.fn(); + const onCreateSubmenu = vi.fn(); + + render( + + ); + + const input = screen.getByPlaceholderText('Type'); + fireEvent.input(input, { target: { value: 'abo' } }); + + const suggestion = await screen.findByRole('button', { name: /^about$/i }); + fireEvent.click(suggestion); + + expect(onSelectPage).toHaveBeenCalledTimes(1); + expect(onSelectPage).toHaveBeenCalledWith(expect.objectContaining({ id: 'page-1' })); + expect(onCreateSubmenu).not.toHaveBeenCalled(); + }); + + it('offers submenu creation from free text', async () => { + const onSelectPage = vi.fn(); + const onCreateSubmenu = vi.fn(); + + render( + + ); + + const input = screen.getByPlaceholderText('Type'); + fireEvent.input(input, { target: { value: 'Products' } }); + + const createOption = await screen.findByRole('button', { name: /add submenu/i }); + fireEvent.click(createOption); + + expect(onCreateSubmenu).toHaveBeenCalledWith('Products'); + expect(onSelectPage).not.toHaveBeenCalled(); + }); +});