Fix mobile tag input dropdown covered by onscreen keyboard with upward dropup popover

This commit is contained in:
chrisf committed 2026-10-26 19:19:44 -06:00
1 parent 322cbea864
commit 67468d9fac
2 files changed
+229 -28

No files matched your search

+1
View File
@@ -29,6 +29,7 @@ export const viewport: Viewport = {
initialScale: 1,
maximumScale: 1,
userScalable: false,
interactiveWidget: 'resizes-content',
};
export default function RootLayout({
+228 -28
View File
@@ -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>
</>