Fix mobile tag input dropdown covered by onscreen keyboard with upward dropup popover
This commit is contained in:
1 parent
322cbea864
commit
67468d9fac
2 files changed
+229
-28
No files matched your search
@@ -29,6 +29,7 @@ export const viewport: Viewport = {
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
interactiveWidget: 'resizes-content',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
@@ -244,8 +244,12 @@ export default function EvercloneWorkspace() {
|
||||
const [activeNote, setActiveNote] = useState<NoteDetail | null>(null);
|
||||
const [saveState, setSaveState] = useState<'saved' | 'saving' | 'dirty'>('saved');
|
||||
|
||||
// Tag input in bottom bar
|
||||
// Tag input in bottom bar & autocomplete popover
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [tagDropdownOpen, setTagDropdownOpen] = useState(false);
|
||||
const [highlightedTagIndex, setHighlightedTagIndex] = useState(-1);
|
||||
const tagInputRef = useRef<HTMLInputElement>(null);
|
||||
const tagDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Quick creation dialogs in sidebar
|
||||
const [creatingNotebook, setCreatingNotebook] = useState(false);
|
||||
@@ -1105,12 +1109,13 @@ export default function EvercloneWorkspace() {
|
||||
const clean = rawTagName.trim();
|
||||
if (!clean || !activeNote) return;
|
||||
const existingNames = activeNote.tags.map((t) => t.name);
|
||||
setTagInput('');
|
||||
setTagDropdownOpen(false);
|
||||
setHighlightedTagIndex(-1);
|
||||
if (existingNames.some((n) => n.toLowerCase() === clean.toLowerCase())) {
|
||||
setTagInput('');
|
||||
return;
|
||||
}
|
||||
const updatedNames = [...existingNames, clean];
|
||||
setTagInput('');
|
||||
await handleImmediatePatch({ tagNames: updatedNames });
|
||||
};
|
||||
|
||||
@@ -1122,6 +1127,94 @@ export default function EvercloneWorkspace() {
|
||||
await handleImmediatePatch({ tagNames: updatedNames });
|
||||
};
|
||||
|
||||
// Close tag dropup when clicking outside
|
||||
useEffect(() => {
|
||||
if (!tagDropdownOpen) return;
|
||||
const handleOutsideClick = (e: MouseEvent | TouchEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (
|
||||
tagDropdownRef.current &&
|
||||
!tagDropdownRef.current.contains(target) &&
|
||||
tagInputRef.current &&
|
||||
!tagInputRef.current.contains(target)
|
||||
) {
|
||||
setTagDropdownOpen(false);
|
||||
setHighlightedTagIndex(-1);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handleOutsideClick);
|
||||
document.addEventListener('touchstart', handleOutsideClick);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick);
|
||||
document.removeEventListener('touchstart', handleOutsideClick);
|
||||
};
|
||||
}, [tagDropdownOpen]);
|
||||
|
||||
// Compute available tags for autocomplete (excluding tags already present on activeNote)
|
||||
const availableTags = useMemo(() => {
|
||||
if (!activeNote) return [];
|
||||
const currentTagNames = new Set(
|
||||
activeNote.tags.map((t) => t.name.toLowerCase())
|
||||
);
|
||||
const unselected = sidebar.tags.filter(
|
||||
(t) => !currentTagNames.has(t.name.toLowerCase())
|
||||
);
|
||||
if (!tagInput.trim()) {
|
||||
return unselected;
|
||||
}
|
||||
const query = tagInput.trim().toLowerCase();
|
||||
return unselected.filter((t) => t.name.toLowerCase().includes(query));
|
||||
}, [activeNote, sidebar.tags, tagInput]);
|
||||
|
||||
const hasExactMatch = useMemo(() => {
|
||||
const query = tagInput.trim().toLowerCase();
|
||||
if (!query || !activeNote) return false;
|
||||
return (
|
||||
activeNote.tags.some((t) => t.name.toLowerCase() === query) ||
|
||||
sidebar.tags.some((t) => t.name.toLowerCase() === query)
|
||||
);
|
||||
}, [tagInput, activeNote, sidebar.tags]);
|
||||
|
||||
const handleTagInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const showCreateOption = !hasExactMatch && Boolean(tagInput.trim());
|
||||
const totalItems = availableTags.length + (showCreateOption ? 1 : 0);
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
if (!tagDropdownOpen) {
|
||||
setTagDropdownOpen(true);
|
||||
setHighlightedTagIndex(0);
|
||||
return;
|
||||
}
|
||||
setHighlightedTagIndex((prev) => (prev + 1 < totalItems ? prev + 1 : 0));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
if (!tagDropdownOpen) {
|
||||
setTagDropdownOpen(true);
|
||||
setHighlightedTagIndex(totalItems - 1);
|
||||
return;
|
||||
}
|
||||
setHighlightedTagIndex((prev) => (prev - 1 >= 0 ? prev - 1 : totalItems - 1));
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (tagDropdownOpen && highlightedTagIndex >= 0) {
|
||||
if (showCreateOption && highlightedTagIndex === 0) {
|
||||
handleAddTagToNote(tagInput.trim());
|
||||
} else {
|
||||
const tagIdx = showCreateOption ? highlightedTagIndex - 1 : highlightedTagIndex;
|
||||
if (availableTags[tagIdx]) {
|
||||
handleAddTagToNote(availableTags[tagIdx].name);
|
||||
}
|
||||
}
|
||||
} else if (tagInput.trim()) {
|
||||
handleAddTagToNote(tagInput.trim());
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
setTagDropdownOpen(false);
|
||||
setHighlightedTagIndex(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateNotebook = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!newNotebookName.trim()) return;
|
||||
@@ -3650,7 +3743,7 @@ export default function EvercloneWorkspace() {
|
||||
)}
|
||||
|
||||
{/* Bottom Tag Bar */}
|
||||
<footer className="px-6 py-2.5 border-t border-gray-200 bg-white flex flex-wrap items-center gap-1.5 text-xs">
|
||||
<footer className="px-3 sm:px-6 py-2.5 border-t border-gray-200 bg-white flex flex-wrap items-center gap-1.5 text-xs relative">
|
||||
<TagIcon className="w-3.5 h-3.5 text-gray-400 mr-1 shrink-0" />
|
||||
{activeNote.tags.map((t) => (
|
||||
<span
|
||||
@@ -3671,31 +3764,138 @@ export default function EvercloneWorkspace() {
|
||||
))}
|
||||
|
||||
{!activeNote.isTrashed && (
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddTagToNote(tagInput);
|
||||
}}
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<input
|
||||
id="everclone-add-tag-input"
|
||||
type="text"
|
||||
list="everclone-tags-datalist"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (tagInput.trim()) handleAddTagToNote(tagInput);
|
||||
<div className="relative inline-flex items-center" ref={tagDropdownRef}>
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
if (tagInput.trim()) {
|
||||
handleAddTagToNote(tagInput);
|
||||
}
|
||||
}}
|
||||
placeholder="+ Add tag…"
|
||||
className="px-2 py-0.5 text-xs text-gray-700 placeholder-gray-400 focus:outline-none focus:bg-gray-100 rounded"
|
||||
/>
|
||||
<datalist id="everclone-tags-datalist">
|
||||
{sidebar.tags.map((t) => (
|
||||
<option key={t.id} value={t.name} />
|
||||
))}
|
||||
</datalist>
|
||||
</form>
|
||||
className="inline-flex items-center"
|
||||
>
|
||||
<input
|
||||
ref={tagInputRef}
|
||||
id="everclone-add-tag-input"
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => {
|
||||
setTagInput(e.target.value);
|
||||
setTagDropdownOpen(true);
|
||||
setHighlightedTagIndex(-1);
|
||||
}}
|
||||
onFocus={() => {
|
||||
setTagDropdownOpen(true);
|
||||
setHighlightedTagIndex(-1);
|
||||
setTimeout(() => {
|
||||
tagInputRef.current?.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'nearest',
|
||||
});
|
||||
}, 150);
|
||||
}}
|
||||
onKeyDown={handleTagInputKeyDown}
|
||||
placeholder="+ Add tag…"
|
||||
className="px-2 py-0.5 text-xs text-gray-700 placeholder-gray-400 focus:outline-none focus:bg-gray-100 rounded border border-transparent focus:border-gray-300 w-24 sm:w-28 focus:w-36 transition-all"
|
||||
/>
|
||||
</form>
|
||||
|
||||
{tagDropdownOpen && (
|
||||
<div
|
||||
className="absolute bottom-full mb-2 left-0 z-50 w-64 max-w-[calc(100vw-2.5rem)] bg-white rounded-xl shadow-2xl border border-gray-200 overflow-hidden flex flex-col"
|
||||
style={{ maxHeight: '240px' }}
|
||||
>
|
||||
<div className="px-3 py-1.5 bg-gray-50 border-b border-gray-200 flex items-center justify-between text-[11px] font-semibold text-gray-500 shrink-0">
|
||||
<span>
|
||||
{tagInput.trim() ? 'Matching Tags' : 'Select or Create Tag'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
setTagDropdownOpen(false);
|
||||
}}
|
||||
className="text-gray-400 hover:text-gray-600 p-0.5 rounded"
|
||||
title="Close"
|
||||
>
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 divide-y divide-gray-100">
|
||||
{/* New tag creation option */}
|
||||
{!hasExactMatch && tagInput.trim() && (
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddTagToNote(tagInput.trim());
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddTagToNote(tagInput.trim());
|
||||
}}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-xs transition ${
|
||||
highlightedTagIndex === 0
|
||||
? 'bg-emerald-100 text-emerald-900 font-semibold'
|
||||
: 'bg-emerald-50/70 text-emerald-800 hover:bg-emerald-100'
|
||||
}`}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 text-emerald-600 shrink-0" />
|
||||
<span className="truncate">
|
||||
Create new tag <strong>"{tagInput.trim()}"</strong>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Existing available tags */}
|
||||
{availableTags.map((tag, idx) => {
|
||||
const itemIndex =
|
||||
!hasExactMatch && tagInput.trim() ? idx + 1 : idx;
|
||||
const isHighlighted = itemIndex === highlightedTagIndex;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={tag.id}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddTagToNote(tag.name);
|
||||
}}
|
||||
onTouchEnd={(e) => {
|
||||
e.preventDefault();
|
||||
handleAddTagToNote(tag.name);
|
||||
}}
|
||||
className={`w-full flex items-center justify-between px-3 py-2 text-left text-xs transition ${
|
||||
isHighlighted
|
||||
? 'bg-emerald-50 text-emerald-900 font-medium'
|
||||
: 'text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<TagIcon className="w-3.5 h-3.5 text-emerald-600 shrink-0" />
|
||||
<span className="truncate">{tag.name}</span>
|
||||
</div>
|
||||
{tag.noteCount !== undefined && (
|
||||
<span className="text-[10px] text-gray-400 shrink-0 ml-2">
|
||||
{tag.noteCount} {tag.noteCount === 1 ? 'note' : 'notes'}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{availableTags.length === 0 &&
|
||||
(!tagInput.trim() || hasExactMatch) && (
|
||||
<div className="px-3 py-3 text-center text-xs text-gray-400 italic">
|
||||
{hasExactMatch
|
||||
? 'Tag already added to note'
|
||||
: 'No existing tags. Type to create one!'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</footer>
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user