Advanced search: Use the shared find-as-you-type for the conditions menu

The conditions menu had its own find-as-you-type, which
Utilities.Internal.addMenuFindAsYouType() now provides for any menu.
Matching walks the menu rather than a cached list of every condition, so
typing can no longer select a condition the menu doesn't offer --
Collection and Saved Search are removed when the search spans multiple
libraries.

An attachment or annotation condition is shown with a short label inside
its submenu, so pass the full name to match on, which is what the
menulist shows once the condition is selected.
This commit is contained in:
Dan Stillman 2026-08-27 14:33:26 -04:00
parent c8b841df8a
commit fa94212496
2 changed files with 29 additions and 70 deletions

View file

@ -958,12 +958,14 @@
var annotationConditionsMenu = this.querySelector('#annotation-conditions-menu');
var conditions = Zotero.SearchConditions.getStandardConditions();
// Cache the (alphabetically sorted) condition list and set up
// find-as-you-type on the closed menu
this._conditions = conditions;
this._typeAheadBuffer = '';
this._typeAheadTime = 0;
conditionsMenu.addEventListener('keydown', event => this.handleConditionKeyDown(event), true);
// Most conditions are in submenus, so the menulist's own find-as-you-type can't
// reach them. Match on the full name rather than the short label the attachment
// and annotation submenus show, since the full name is what the menulist shows
// once the condition is selected.
Zotero.Utilities.Internal.addMenuFindAsYouType(
conditionsMenu.menupopup,
item => Zotero.SearchConditions.getLocalizedName(item.value)
);
// Label the submenus and seed the top-level entries with them, so the
// headings sort alphabetically alongside the primary conditions
@ -1681,70 +1683,6 @@
return false;
}
// Find-as-you-type on the closed conditions menu. The native incremental
// find within the open popup only matches the visible top-level items, so
// this handles typing while the menu is closed to reach any condition,
// including the ~67 in the "More" submenu.
handleConditionKeyDown(event) {
var menu = this.querySelector('#conditionsmenu');
// Let the native incremental find handle typing while the popup is open,
// and ignore in-progress IME composition (event.key is "Process")
if (menu.open || event.isComposing || event.ctrlKey || event.metaKey || event.altKey) {
return;
}
// Only act on a single printable character. Count code points rather
// than UTF-16 units so supplementary-plane characters (e.g. some CJK
// extension blocks) aren't treated as multi-key sequences.
if (Array.from(event.key).length != 1) {
return;
}
var now = Date.now();
// Start a new search if enough time has passed since the last keystroke
if (now - this._typeAheadTime > 1000) {
this._typeAheadBuffer = '';
}
this._typeAheadTime = now;
// With no search in progress, a space opens the menu instead of starting
// a search, matching the native menulist behavior
if (event.key == ' ' && !this._typeAheadBuffer) {
return;
}
this._typeAheadBuffer += event.key.toLowerCase();
var conditionName = this.findConditionByPrefix();
if (conditionName) {
event.preventDefault();
event.stopPropagation();
this.onConditionSelected(conditionName);
}
}
findConditionByPrefix() {
var buffer = this._typeAheadBuffer;
var conditions = this._conditions;
// When the same character is typed repeatedly, cycle through the
// matching conditions, starting after the currently selected one
var cycling = buffer.length > 1 && [...buffer].every(c => c == buffer[0]);
var prefix = cycling ? buffer[0] : buffer;
var startIndex = 0;
if (cycling) {
startIndex = conditions.findIndex(c => c.name == this.selectedCondition) + 1;
}
for (let i = 0; i < conditions.length; i++) {
let condition = conditions[(startIndex + i) % conditions.length];
if (condition.localized.toLowerCase().startsWith(prefix)) {
return condition.name;
}
}
return null;
}
onRemoveClicked(event) {
if (this.parent) {
// A keyboard-synthesized click has detail 0, unlike a mouse click

View file

@ -1110,6 +1110,27 @@ describe("Advanced Search", function () {
assert.equal(searchCondition.selectedCondition, 'language');
});
it("should select an attachment condition by its full name", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;
s.addCondition('title', 'is', '');
pane.search = s;
var searchCondition = conditions.firstChild;
var conditionsMenu = searchCondition.querySelector('#conditionsmenu');
// The Attachment submenu shows a short label, but the menulist shows the
// full name, which is what gets typed
let menuitem = conditionsMenu.querySelector('menuitem[value="fileTypeID"]');
assert.notEqual(
menuitem.label,
Zotero.SearchConditions.getLocalizedName('fileTypeID')
);
// The spaces in the name would otherwise open the menu (see customElements.js)
typeInMenu(conditionsMenu, 'attachment file t');
assert.equal(searchCondition.selectedCondition, 'fileTypeID');
});
it("should cycle through matches when the same letter is typed repeatedly", function () {
var s = new Zotero.Search();
s.libraryID = Zotero.Libraries.userLibraryID;