fix: submenu editing now finally works kinda like I want it

This commit is contained in:
2026-02-21 21:18:30 +01:00
parent 4bd0e5ab19
commit 3a30e9bc41
7 changed files with 418 additions and 160 deletions

View File

@@ -120,11 +120,18 @@
} }
.menu-editor-row-title { .menu-editor-row-title {
flex: 1;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.menu-editor-row-title.is-editing {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.menu-editor-inline-input { .menu-editor-inline-input {
width: 100%; width: 100%;
border: 1px solid var(--vscode-focusBorder); border: 1px solid var(--vscode-focusBorder);
@@ -157,6 +164,12 @@
gap: 0.75rem; gap: 0.75rem;
} }
.menu-editor-inline-actions {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.menu-editor-inline-search-head strong { .menu-editor-inline-search-head strong {
font-size: 0.8rem; font-size: 0.8rem;
} }
@@ -166,6 +179,19 @@
font-size: 0.75rem; 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 { .menu-editor-picker-backdrop {
position: absolute; position: absolute;
inset: 0; inset: 0;

View File

@@ -3,9 +3,10 @@ import { Tree } from 'react-arborist';
import { useI18n } from '../../i18n'; import { useI18n } from '../../i18n';
import { showToast } from '../Toast'; import { showToast } from '../Toast';
import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi'; import type { MenuDocument, MenuItemData, PostData } from '../../../main/shared/electronApi';
import { PageInput } from '../PageInput';
import { createAutoExpandController } from './menuAutoExpand'; import { createAutoExpandController } from './menuAutoExpand';
import { resolveInsertTarget } from './menuInsertTarget'; import { resolveInsertTarget } from './menuInsertTarget';
import { filterPagePosts, isPickerCloseKey, isPickerFocusShortcut } from './menuPagePicker'; import { isPickerCloseKey } from './menuPagePicker';
import { applyTreeMove } from './menuTreeMove'; import { applyTreeMove } from './menuTreeMove';
import './MenuEditorView.css'; import './MenuEditorView.css';
@@ -171,11 +172,8 @@ export const MenuEditorView: React.FC = () => {
const [isLoadingPages, setIsLoadingPages] = useState(false); const [isLoadingPages, setIsLoadingPages] = useState(false);
const [pagePosts, setPagePosts] = useState<PostData[]>([]); const [pagePosts, setPagePosts] = useState<PostData[]>([]);
const [editingEntryId, setEditingEntryId] = useState<string | null>(null); const [editingEntryId, setEditingEntryId] = useState<string | null>(null);
const [editingText, setEditingText] = useState('');
const [selectedPageId, setSelectedPageId] = useState<string | null>(null);
const [toolbarTooltip, setToolbarTooltip] = useState<string>(''); const [toolbarTooltip, setToolbarTooltip] = useState<string>('');
const [recentParentInsertId, setRecentParentInsertId] = useState<string | null>(null); const [recentParentInsertId, setRecentParentInsertId] = useState<string | null>(null);
const entryInputRef = useRef<HTMLInputElement | null>(null);
const recentInsertTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const recentInsertTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoExpandController = useMemo(() => createAutoExpandController(450), []); const autoExpandController = useMemo(() => createAutoExpandController(450), []);
@@ -212,13 +210,6 @@ export const MenuEditorView: React.FC = () => {
} }
const onWindowKeyDown = (event: KeyboardEvent): void => { 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)) { if (isPickerCloseKey(event.key)) {
event.preventDefault(); event.preventDefault();
setItems((previous) => { setItems((previous) => {
@@ -229,24 +220,37 @@ export const MenuEditorView: React.FC = () => {
return removeItemByPath(previous, path).next; return removeItemByPath(previous, path).next;
}); });
setEditingEntryId(null); setEditingEntryId(null);
setEditingText('');
setSelectedPageId(null);
} }
}; };
window.addEventListener('keydown', onWindowKeyDown); document.addEventListener('keydown', onWindowKeyDown);
return () => { return () => {
window.removeEventListener('keydown', onWindowKeyDown); document.removeEventListener('keydown', onWindowKeyDown);
}; };
}, [editingEntryId]); }, [editingEntryId]);
useEffect(() => { useEffect(() => {
if (!editingEntryId) { if (!editingEntryId || isLoadingPages) {
return; return;
} }
entryInputRef.current?.focus(); const focusInput = (): void => {
}, [editingEntryId]); 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(() => { const selectedPath = useMemo(() => {
if (!selectedId) { if (!selectedId) {
@@ -255,13 +259,6 @@ export const MenuEditorView: React.FC = () => {
return findPathById(items, selectedId); return findPathById(items, selectedId);
}, [items, selectedId]); }, [items, selectedId]);
const filteredPagePosts = useMemo(() => {
if (!editingEntryId) {
return [];
}
return filterPagePosts(pagePosts, editingText);
}, [editingEntryId, pagePosts, editingText]);
const ensurePagePostsLoaded = async (): Promise<void> => { const ensurePagePostsLoaded = async (): Promise<void> => {
if (pagePosts.length > 0) { if (pagePosts.length > 0) {
return; return;
@@ -280,15 +277,14 @@ export const MenuEditorView: React.FC = () => {
} }
}; };
const finalizeEntry = (): void => { const setDraftAsSubmenu = (label: string): void => {
if (!editingEntryId) { if (!editingEntryId) {
return; return;
} }
const selectedPage = selectedPageId ? pagePosts.find((post) => post.id === selectedPageId) : null; const trimmed = label.trim();
const trimmed = editingText.trim(); const nextTitle = trimmed || tr('menuEditor.newSubmenu');
if (selectedPage) {
setItems((previous) => mapItems(previous, (item) => { setItems((previous) => mapItems(previous, (item) => {
if (item.id !== editingEntryId) { if (item.id !== editingEntryId) {
return item; return item;
@@ -296,43 +292,17 @@ export const MenuEditorView: React.FC = () => {
return { return {
...item, ...item,
title: selectedPage.title, title: nextTitle,
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', kind: 'submenu',
pageId: undefined, pageId: undefined,
pageSlug: undefined, pageSlug: undefined,
}; };
})); }));
} else {
setItems((previous) => {
const path = findPathById(previous, editingEntryId);
if (!path) {
return previous;
}
return removeItemByPath(previous, path).next;
});
setSelectedId(null);
}
setEditingEntryId(null); setEditingEntryId(null);
setEditingText('');
setSelectedPageId(null);
}; };
const finalizeEntryWithPage = (post: PostData): void => { const setDraftAsPage = (post: PostData): void => {
if (!editingEntryId) { if (!editingEntryId) {
return; return;
} }
@@ -352,8 +322,6 @@ export const MenuEditorView: React.FC = () => {
})); }));
setEditingEntryId(null); setEditingEntryId(null);
setEditingText('');
setSelectedPageId(null);
}; };
const startCreateEntry = async (): Promise<void> => { const startCreateEntry = async (): Promise<void> => {
@@ -392,8 +360,6 @@ export const MenuEditorView: React.FC = () => {
setSelectedId(newEntry.id); setSelectedId(newEntry.id);
setEditingEntryId(newEntry.id); setEditingEntryId(newEntry.id);
setEditingText('');
setSelectedPageId(null);
}; };
const save = async (): Promise<void> => { const save = async (): Promise<void> => {
@@ -486,8 +452,6 @@ export const MenuEditorView: React.FC = () => {
if (editingEntryId === selectedId) { if (editingEntryId === selectedId) {
setEditingEntryId(null); setEditingEntryId(null);
setEditingText('');
setSelectedPageId(null);
} }
setSelectedId(null); setSelectedId(null);
}; };
@@ -622,79 +586,24 @@ export const MenuEditorView: React.FC = () => {
<span className="menu-editor-row-kind"> <span className="menu-editor-row-kind">
{node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')} {node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')}
</span> </span>
<span className="menu-editor-row-title">{node.data.title}</span> <span className={`menu-editor-row-title ${editingEntryId === node.data.id ? 'is-editing' : ''}`}>
{editingEntryId === node.data.id ? (
<PageInput
pages={pagePosts}
onSelectPage={setDraftAsPage}
onCreateSubmenu={setDraftAsSubmenu}
createSubmenuLabel={tr('menuEditor.addSubmenu')}
placeholder={tr('menuEditor.newEntryPlaceholder')}
disabled={isLoadingPages}
autoFocus
/>
) : node.data.title}
</span>
</> </>
</div> </div>
)} )}
</Tree> </Tree>
)} )}
{editingEntryId && (
<div className="menu-editor-inline-search">
<div className="menu-editor-entry-editor">
<input
ref={entryInputRef}
type="text"
className="menu-editor-inline-input"
value={editingText}
onChange={(event) => {
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')}
/>
</div>
<div className="menu-editor-inline-search-head">
<strong>{tr('menuEditor.pagePicker.title')}</strong>
<span>{tr('menuEditor.createHint')}</span>
</div>
{isLoadingPages ? (
<div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.loading')}</div>
) : filteredPagePosts.length === 0 ? (
<div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.empty')}</div>
) : (
<div className="menu-editor-picker-list">
{filteredPagePosts.map((post) => (
<button
key={post.id}
type="button"
className={`menu-editor-picker-item ${selectedPageId === post.id ? 'is-active' : ''}`}
onClick={() => {
setSelectedPageId(post.id);
setEditingText(post.title);
}}
onDoubleClick={() => {
finalizeEntryWithPage(post);
}}
>
<span>{post.title}</span>
<small>/{post.slug}</small>
</button>
))}
</div>
)}
</div>
)}
</div> </div>
</div> </div>
)} )}

View File

@@ -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<PageInputProps> = ({
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<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(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<HTMLInputElement>): 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 (
<div className="tag-input-container" ref={containerRef}>
<div className="tag-input-wrapper">
<input
ref={inputRef}
type="text"
className="tag-input-field"
value={inputValue}
autoFocus={autoFocus}
onChange={(event) => {
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"
/>
</div>
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
<div className="tag-suggestions">
{suggestions.map((page, index) => (
<button
key={page.id}
type="button"
className={`tag-suggestion ${selectedIndex === index ? 'selected' : ''}`}
onClick={() => selectPage(page)}
>
<span className="tag-suggestion-name">{page.title}</span>
</button>
))}
{showCreateOption && (
<button
type="button"
className={`tag-suggestion create-new ${selectedIndex === suggestions.length ? 'selected' : ''}`}
onClick={() => createSubmenu(inputValue)}
>
<span className="tag-suggestion-icon">+</span>
<span>{createSubmenuLabel}</span>
</button>
)}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1 @@
export { PageInput } from './PageInput';

View File

@@ -261,6 +261,10 @@ export const TagInput: React.FC<TagInputProps> = ({
setInputValue(e.target.value); setInputValue(e.target.value);
setShowSuggestions(true); setShowSuggestions(true);
}} }}
onInput={(e) => {
setInputValue((e.target as HTMLInputElement).value);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)} onFocus={() => setShowSuggestions(true)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder={value.length === 0 ? placeholder : ''} placeholder={value.length === 0 ? placeholder : ''}

View File

@@ -8,9 +8,6 @@ describe('MenuEditorView entry editor', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
(window as any).addEventListener = vi.fn();
(window as any).removeEventListener = vi.fn();
(window as any).electronAPI = { (window as any).electronAPI = {
...(window as any).electronAPI, ...(window as any).electronAPI,
menu: { 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(<MenuEditorView />); const { container } = render(<MenuEditorView />);
const addButton = await screen.findByRole('button', { name: /add entry/i }); const addButton = await screen.findByRole('button', { name: /add entry/i });
fireEvent.click(addButton); fireEvent.click(addButton);
const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); 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.input(input, { target: { value: 'ab' } });
fireEvent.change(input, { target: { value: 'ab' } }); const suggestion = await screen.findByRole('button', { name: /^about$/i });
fireEvent.change(input, { target: { value: 'abc' } }); expect(suggestion.className).toContain('tag-suggestion');
expect((input as HTMLInputElement).value).toBe('abc');
expect(document.activeElement).toBe(input);
expect(container.querySelector('.menu-editor-row .menu-editor-inline-input')).toBeNull();
}); });
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(<MenuEditorView />);
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(<MenuEditorView />);
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(<MenuEditorView />);
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(<MenuEditorView />);
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) => ({ const pagePosts = Array.from({ length: 12 }).map((_, index) => ({
id: `page-${index + 1}`, id: `page-${index + 1}`,
projectId: 'project-1', projectId: 'project-1',
@@ -87,10 +140,10 @@ describe('MenuEditorView entry editor', () => {
fireEvent.click(addButton); fireEvent.click(addButton);
const input = await screen.findByPlaceholderText(/type a page title or submenu label/i); 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 }); 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 () => { 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'); 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(<MenuEditorView />); render(<MenuEditorView />);
const addButton = await screen.findByRole('button', { name: /add entry/i }); const addButton = await screen.findByRole('button', { name: /add entry/i });
fireEvent.click(addButton); 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 }); 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.queryByPlaceholderText(/type a page title or submenu label/i)).not.toBeInTheDocument();
expect(screen.getByText('About')).toBeInTheDocument(); expect(screen.getByText('About')).toBeInTheDocument();

View File

@@ -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(
<PageInput
pages={pages}
onSelectPage={onSelectPage}
onCreateSubmenu={onCreateSubmenu}
placeholder="Type"
createSubmenuLabel="Add Submenu"
/>
);
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(
<PageInput
pages={pages}
onSelectPage={onSelectPage}
onCreateSubmenu={onCreateSubmenu}
placeholder="Type"
createSubmenuLabel="Add Submenu"
/>
);
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();
});
});