fix: submenu editing now finally works kinda like I want it
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
180
src/renderer/components/PageInput/PageInput.tsx
Normal file
180
src/renderer/components/PageInput/PageInput.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
src/renderer/components/PageInput/index.ts
Normal file
1
src/renderer/components/PageInput/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { PageInput } from './PageInput';
|
||||||
@@ -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 : ''}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
82
tests/renderer/components/PageInput.test.tsx
Normal file
82
tests/renderer/components/PageInput.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user