fix(autocomplete): preserve manual accept-key selection

This commit is contained in:
Will Miao
2026-04-15 21:19:00 +08:00
parent 2640258902
commit 439679e15f
2 changed files with 87 additions and 13 deletions

View File

@@ -372,6 +372,66 @@ describe('AutoComplete widget interactions', () => {
expect(insertSelectionSpy).toHaveBeenCalledWith('loop'); expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
}); });
it('preserves manual ArrowDown selection when Tab accepts a suggestion', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
const input = document.createElement('textarea');
input.value = 'loop';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
autoComplete.searchType = 'custom_words';
autoComplete.items = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
{ tag_name: 'loop', category: 0, post_count: 500 },
];
autoComplete.currentSearchTerm = 'loo';
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
expect(autoComplete.selectedIndex).toBe(1);
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('preserves manual ArrowDown selection when Enter accepts a suggestion', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop');
const input = document.createElement('textarea');
input.value = 'loop';
input.selectionStart = input.value.length;
input.focus = vi.fn();
input.setSelectionRange = vi.fn();
document.body.append(input);
const { AutoComplete } = await import(AUTOCOMPLETE_MODULE);
const autoComplete = new AutoComplete(input,'prompt', { showPreview: false, minChars: 1 });
autoComplete.searchType = 'custom_words';
autoComplete.items = [
{ tag_name: 'looking_to_the_side', category: 0, post_count: 1000 },
{ tag_name: 'loop', category: 0, post_count: 500 },
];
autoComplete.currentSearchTerm = 'loo';
autoComplete.selectedIndex = 0;
autoComplete.isVisible = true;
const insertSelectionSpy = vi.spyOn(autoComplete,'insertSelection').mockResolvedValue();
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }));
expect(autoComplete.selectedIndex).toBe(1);
expect(insertSelectionSpy).toHaveBeenCalledWith('loop');
});
it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => { it('accepts the first available suggestion with Tab even if delayed auto-selection has not happened yet', async () => {
caretHelperInstance.getBeforeCursor.mockReturnValue('loop'); caretHelperInstance.getBeforeCursor.mockReturnValue('loop');

View File

@@ -358,6 +358,7 @@ class AutoComplete {
this.dropdown = null; this.dropdown = null;
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
this.items = []; this.items = [];
this.debounceTimer = null; this.debounceTimer = null;
this.isVisible = false; this.isVisible = false;
@@ -1139,6 +1140,14 @@ class AutoComplete {
return 0; return 0;
} }
_getAcceptSelectionIndex(searchTerm = '') {
if (this.hasManualSelection && this.selectedIndex >= 0 && this.selectedIndex < this.items.length) {
return this.selectedIndex;
}
return this._getPreferredSelectedIndex(searchTerm);
}
async search(term = '', endpoint = null) { async search(term = '', endpoint = null) {
try { try {
this.currentSearchTerm = term; this.currentSearchTerm = term;
@@ -1339,6 +1348,7 @@ class AutoComplete {
this.dropdown.innerHTML = ''; this.dropdown.innerHTML = '';
} }
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
this.items.forEach((item, index) => { this.items.forEach((item, index) => {
const itemEl = document.createElement('div'); const itemEl = document.createElement('div');
@@ -1374,7 +1384,7 @@ class AutoComplete {
`; `;
itemEl.addEventListener('mouseenter', () => { itemEl.addEventListener('mouseenter', () => {
this.selectItem(index); this.selectItem(index, { manual: true });
}); });
itemEl.addEventListener('click', () => { itemEl.addEventListener('click', () => {
@@ -1401,6 +1411,7 @@ class AutoComplete {
// full command list with a partially virtualized slice. // full command list with a partially virtualized slice.
if (this.items.length > 0) { if (this.items.length > 0) {
this.selectedIndex = 0; this.selectedIndex = 0;
this.hasManualSelection = false;
if (this.contentContainer) { if (this.contentContainer) {
this._applyItemSelection(0); this._applyItemSelection(0);
} else { } else {
@@ -1443,6 +1454,7 @@ class AutoComplete {
render() { render() {
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
// Reset virtual scroll state // Reset virtual scroll state
this.virtualScrollOffset = 0; this.virtualScrollOffset = 0;
@@ -1542,7 +1554,7 @@ class AutoComplete {
// Hover and selection handlers // Hover and selection handlers
item.addEventListener('mouseenter', () => { item.addEventListener('mouseenter', () => {
this.selectItem(index); this.selectItem(index, { manual: true });
}); });
item.addEventListener('mouseleave', () => { item.addEventListener('mouseleave', () => {
@@ -1991,7 +2003,7 @@ class AutoComplete {
// Hover and selection handlers // Hover and selection handlers
item.addEventListener('mouseenter', () => { item.addEventListener('mouseenter', () => {
this.selectItem(index); this.selectItem(index, { manual: true });
}); });
item.addEventListener('mouseleave', () => { item.addEventListener('mouseleave', () => {
@@ -2083,6 +2095,7 @@ class AutoComplete {
this.dropdown.style.display = 'none'; this.dropdown.style.display = 'none';
this.isVisible = false; this.isVisible = false;
this.selectedIndex = -1; this.selectedIndex = -1;
this.hasManualSelection = false;
this.showingCommands = false; this.showingCommands = false;
// Clear items to prevent stale data from being displayed // Clear items to prevent stale data from being displayed
@@ -2121,7 +2134,7 @@ class AutoComplete {
}); });
} }
selectItem(index) { selectItem(index, { manual = false } = {}) {
// Remove previous selection // Remove previous selection
const container = this.options.enableVirtualScroll && this.contentContainer const container = this.options.enableVirtualScroll && this.contentContainer
? this.contentContainer ? this.contentContainer
@@ -2135,6 +2148,7 @@ class AutoComplete {
// Add new selection // Add new selection
if (index >= 0 && index < this.items.length) { if (index >= 0 && index < this.items.length) {
this.selectedIndex = index; this.selectedIndex = index;
this.hasManualSelection = manual;
// For virtual scrolling, we need to ensure the item is rendered // For virtual scrolling, we need to ensure the item is rendered
if (this.options.enableVirtualScroll && this.scrollContainer) { if (this.options.enableVirtualScroll && this.scrollContainer) {
@@ -2228,15 +2242,15 @@ class AutoComplete {
this.loadMoreItems().then(() => { this.loadMoreItems().then(() => {
// After loading more, select the next item // After loading more, select the next item
if (this.selectedIndex < this.items.length - 1) { if (this.selectedIndex < this.items.length - 1) {
this.selectItem(this.selectedIndex + 1); this.selectItem(this.selectedIndex + 1, { manual: true });
} }
}); });
} }
} else { } else {
this.selectItem(this.selectedIndex + 1); this.selectItem(this.selectedIndex + 1, { manual: true });
} }
} else { } else {
this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1)); this.selectItem(Math.min(this.selectedIndex + 1, this.items.length - 1), { manual: true });
} }
break; break;
@@ -2246,12 +2260,12 @@ class AutoComplete {
// For virtual scrolling, handle top boundary // For virtual scrolling, handle top boundary
if (this.selectedIndex <= 0) { if (this.selectedIndex <= 0) {
// Already at first item, ensure it's selected // Already at first item, ensure it's selected
this.selectItem(0); this.selectItem(0, { manual: true });
} else { } else {
this.selectItem(this.selectedIndex - 1); this.selectItem(this.selectedIndex - 1, { manual: true });
} }
} else { } else {
this.selectItem(Math.max(this.selectedIndex - 1, 0)); this.selectItem(Math.max(this.selectedIndex - 1, 0), { manual: true });
} }
break; break;
@@ -2263,9 +2277,9 @@ class AutoComplete {
{ {
const liveSearchTerm = this._getLiveSearchTermForAcceptance(); const liveSearchTerm = this._getLiveSearchTermForAcceptance();
const preferredIndex = this._getPreferredSelectedIndex(liveSearchTerm); const acceptIndex = this._getAcceptSelectionIndex(liveSearchTerm);
if (preferredIndex !== -1 && preferredIndex !== this.selectedIndex) { if (acceptIndex !== -1 && acceptIndex !== this.selectedIndex) {
this.selectItem(preferredIndex); this.selectItem(acceptIndex);
} }
} }