blob: 52706f9a1b6a3e939afb2d315dfa9f9a73d37f02 [file] [log] [blame]
/*
* Copyright (c) 2012 The Chromium Authors. All rights reserved.
* Use of this source code is governed by a BSD-style license that can be
* found in the LICENSE file.
*/
/**
* @unrestricted
* @implements {UI.ListDelegate}
*/
export class FilteredListWidget extends UI.VBox {
/**
* @param {?QuickOpen.FilteredListWidget.Provider} provider
* @param {!Array<string>=} promptHistory
* @param {function(string)=} queryChangedCallback
*/
constructor(provider, promptHistory, queryChangedCallback) {
super(true);
this._promptHistory = promptHistory || [];
this.contentElement.classList.add('filtered-list-widget');
this.contentElement.addEventListener('keydown', this._onKeyDown.bind(this), true);
UI.ARIAUtils.markAsCombobox(this.contentElement);
this.registerRequiredCSS('quick_open/filteredListWidget.css');
this._promptElement = this.contentElement.createChild('div', 'filtered-list-widget-input');
UI.ARIAUtils.setAccessibleName(this._promptElement, ls`Quick open prompt`);
this._promptElement.setAttribute('spellcheck', 'false');
this._promptElement.setAttribute('contenteditable', 'plaintext-only');
this._prompt = new UI.TextPrompt();
this._prompt.initialize(() => Promise.resolve([]));
const promptProxy = this._prompt.attach(this._promptElement);
promptProxy.addEventListener('input', this._onInput.bind(this), false);
promptProxy.classList.add('filtered-list-widget-prompt-element');
this._bottomElementsContainer = this.contentElement.createChild('div', 'vbox');
this._progressElement = this._bottomElementsContainer.createChild('div', 'filtered-list-widget-progress');
this._progressBarElement = this._progressElement.createChild('div', 'filtered-list-widget-progress-bar');
/** @type {!UI.ListModel<number>} */
this._items = new UI.ListModel();
/** @type {!UI.ListControl<number>} */
this._list = new UI.ListControl(this._items, this, UI.ListMode.EqualHeightItems);
this._itemElementsContainer = this._list.element;
this._itemElementsContainer.classList.add('container');
this._bottomElementsContainer.appendChild(this._itemElementsContainer);
this._itemElementsContainer.addEventListener('click', this._onClick.bind(this), false);
UI.ARIAUtils.markAsListBox(this._itemElementsContainer);
UI.ARIAUtils.setControls(this._promptElement, this._itemElementsContainer);
UI.ARIAUtils.setAutocomplete(this._promptElement, UI.ARIAUtils.AutocompleteInteractionModel.list);
this._notFoundElement = this._bottomElementsContainer.createChild('div', 'not-found-text');
this._notFoundElement.classList.add('hidden');
this.setDefaultFocusedElement(this._promptElement);
this._prefix = '';
this._provider = provider;
this._queryChangedCallback = queryChangedCallback;
}
/**
* @param {!Element} element
* @param {string} query
* @param {boolean=} caseInsensitive
* @return {boolean}
*/
static highlightRanges(element, query, caseInsensitive) {
if (!query) {
return false;
}
/**
* @param {string} text
* @param {string} query
* @return {?Array.<!TextUtils.SourceRange>}
*/
function rangesForMatch(text, query) {
const opcodes = Diff.Diff.charDiff(query, text);
let offset = 0;
const ranges = [];
for (let i = 0; i < opcodes.length; ++i) {
const opcode = opcodes[i];
if (opcode[0] === Diff.Diff.Operation.Equal) {
ranges.push(new TextUtils.SourceRange(offset, opcode[1].length));
} else if (opcode[0] !== Diff.Diff.Operation.Insert) {
return null;
}
offset += opcode[1].length;
}
return ranges;
}
const text = element.textContent;
let ranges = rangesForMatch(text, query);
if (!ranges || caseInsensitive) {
ranges = rangesForMatch(text.toUpperCase(), query.toUpperCase());
}
if (ranges) {
UI.highlightRangesWithStyleClass(element, ranges, 'highlight');
return true;
}
return false;
}
/**
* @param {string} placeholder
* @param {string=} ariaPlaceholder
*/
setPlaceholder(placeholder, ariaPlaceholder) {
this._prompt.setPlaceholder(placeholder, ariaPlaceholder);
}
showAsDialog() {
this._dialog = new UI.Dialog();
UI.ARIAUtils.setAccessibleName(this._dialog.contentElement, ls`Quick open`);
this._dialog.setMaxContentSize(new UI.Size(504, 340));
this._dialog.setSizeBehavior(UI.GlassPane.SizeBehavior.SetExactWidthMaxHeight);
this._dialog.setContentPosition(null, 22);
this.show(this._dialog.contentElement);
UI.ARIAUtils.setExpanded(this.contentElement, true);
this._dialog.show();
}
/**
* @param {string} prefix
*/
setPrefix(prefix) {
this._prefix = prefix;
}
/**
* @param {?QuickOpen.FilteredListWidget.Provider} provider
*/
setProvider(provider) {
if (provider === this._provider) {
return;
}
if (this._provider) {
this._provider.detach();
}
this._clearTimers();
this._provider = provider;
if (this.isShowing()) {
this._attachProvider();
}
}
_attachProvider() {
this._items.replaceAll([]);
this._list.invalidateItemHeight();
if (this._provider) {
this._provider.setRefreshCallback(this._itemsLoaded.bind(this, this._provider));
this._provider.attach();
}
this._itemsLoaded(this._provider);
}
/**
* @return {string}
*/
_value() {
return this._prompt.text().trim();
}
_cleanValue() {
return this._value().substring(this._prefix.length);
}
/**
* @override
*/
wasShown() {
this._attachProvider();
}
/**
* @override
*/
willHide() {
if (this._provider) {
this._provider.detach();
}
this._clearTimers();
UI.ARIAUtils.setExpanded(this.contentElement, false);
}
_clearTimers() {
clearTimeout(this._filterTimer);
clearTimeout(this._scoringTimer);
clearTimeout(this._loadTimeout);
delete this._filterTimer;
delete this._scoringTimer;
delete this._loadTimeout;
delete this._refreshListWithCurrentResult;
}
/**
* @param {!Event} event
*/
_onEnter(event) {
if (!this._provider) {
return;
}
const selectedIndexInProvider = this._provider.itemCount() ? this._list.selectedItem() : null;
this._selectItem(selectedIndexInProvider);
if (this._dialog) {
this._dialog.hide();
}
}
/**
* @param {?QuickOpen.FilteredListWidget.Provider} provider
*/
_itemsLoaded(provider) {
if (this._loadTimeout || provider !== this._provider) {
return;
}
this._loadTimeout = setTimeout(this._updateAfterItemsLoaded.bind(this), 0);
}
_updateAfterItemsLoaded() {
delete this._loadTimeout;
this._filterItems();
}
/**
* @override
* @param {number} item
* @return {!Element}
*/
createElementForItem(item) {
const itemElement = createElement('div');
itemElement.className = 'filtered-list-widget-item ' + (this._provider.renderAsTwoRows() ? 'two-rows' : 'one-row');
const titleElement = itemElement.createChild('div', 'filtered-list-widget-title');
const subtitleElement = itemElement.createChild('div', 'filtered-list-widget-subtitle');
subtitleElement.textContent = '\u200B';
this._provider.renderItem(item, this._cleanValue(), titleElement, subtitleElement);
UI.ARIAUtils.markAsOption(itemElement);
return itemElement;
}
/**
* @override
* @param {number} item
* @return {number}
*/
heightForItem(item) {
// Let the list measure items for us.
return 0;
}
/**
* @override
* @param {number} item
* @return {boolean}
*/
isItemSelectable(item) {
return true;
}
/**
* @override
* @param {?number} from
* @param {?number} to
* @param {?Element} fromElement
* @param {?Element} toElement
*/
selectedItemChanged(from, to, fromElement, toElement) {
if (fromElement) {
fromElement.classList.remove('selected');
}
if (toElement) {
toElement.classList.add('selected');
}
UI.ARIAUtils.setActiveDescendant(this._promptElement, toElement);
}
/**
* @param {!Event} event
*/
_onClick(event) {
const item = this._list.itemForNode(/** @type {?Node} */ (event.target));
if (item === null) {
return;
}
event.consume(true);
this._selectItem(item);
if (this._dialog) {
this._dialog.hide();
}
}
/**
* @param {string} query
*/
setQuery(query) {
this._prompt.focus();
this._prompt.setText(query);
this._queryChanged();
this._prompt.autoCompleteSoon(true);
this._scheduleFilter();
}
/**
* @return {boolean}
*/
_tabKeyPressed() {
const userEnteredText = this._prompt.text();
let completion;
for (let i = this._promptHistory.length - 1; i >= 0; i--) {
if (this._promptHistory[i] !== userEnteredText && this._promptHistory[i].startsWith(userEnteredText)) {
completion = this._promptHistory[i];
break;
}
}
if (!completion) {
return false;
}
this._prompt.focus();
this._prompt.setText(completion);
this._prompt.setDOMSelection(userEnteredText.length, completion.length);
this._scheduleFilter();
return true;
}
_itemsFilteredForTest() {
// Sniffed in tests.
}
_filterItems() {
delete this._filterTimer;
if (this._scoringTimer) {
clearTimeout(this._scoringTimer);
delete this._scoringTimer;
if (this._refreshListWithCurrentResult) {
this._refreshListWithCurrentResult();
}
}
if (!this._provider) {
this._bottomElementsContainer.classList.toggle('hidden', true);
this._itemsFilteredForTest();
return;
}
this._bottomElementsContainer.classList.toggle('hidden', false);
this._progressBarElement.style.transform = 'scaleX(0)';
this._progressBarElement.classList.remove('filtered-widget-progress-fade');
this._progressBarElement.classList.remove('hidden');
const query = this._provider.rewriteQuery(this._cleanValue());
this._query = query;
const filterRegex = query ? String.filterRegex(query) : null;
const filteredItems = [];
const bestScores = [];
const bestItems = [];
const bestItemsToCollect = 100;
let minBestScore = 0;
const overflowItems = [];
const scoreStartTime = window.performance.now();
const maxWorkItems = Number.constrain(10, 500, (this._provider.itemCount() / 10) | 0);
scoreItems.call(this, 0);
/**
* @param {number} a
* @param {number} b
* @return {number}
*/
function compareIntegers(a, b) {
return b - a;
}
/**
* @param {number} fromIndex
* @this {QuickOpen.FilteredListWidget}
*/
function scoreItems(fromIndex) {
delete this._scoringTimer;
let workDone = 0;
let i;
for (i = fromIndex; i < this._provider.itemCount() && workDone < maxWorkItems; ++i) {
// Filter out non-matching items quickly.
if (filterRegex && !filterRegex.test(this._provider.itemKeyAt(i))) {
continue;
}
// Score item.
const score = this._provider.itemScoreAt(i, query);
if (query) {
workDone++;
}
// Find its index in the scores array (earlier elements have bigger scores).
if (score > minBestScore || bestScores.length < bestItemsToCollect) {
const index = bestScores.upperBound(score, compareIntegers);
bestScores.splice(index, 0, score);
bestItems.splice(index, 0, i);
if (bestScores.length > bestItemsToCollect) {
// Best list is too large -> drop last elements.
overflowItems.push(bestItems.peekLast());
bestScores.length = bestItemsToCollect;
bestItems.length = bestItemsToCollect;
}
minBestScore = bestScores.peekLast();
} else {
filteredItems.push(i);
}
}
this._refreshListWithCurrentResult = this._refreshList.bind(this, bestItems, overflowItems, filteredItems);
// Process everything in chunks.
if (i < this._provider.itemCount()) {
this._scoringTimer = setTimeout(scoreItems.bind(this, i), 0);
if (window.performance.now() - scoreStartTime > 50) {
this._progressBarElement.style.transform = 'scaleX(' + i / this._provider.itemCount() + ')';
}
return;
}
if (window.performance.now() - scoreStartTime > 100) {
this._progressBarElement.style.transform = 'scaleX(1)';
this._progressBarElement.classList.add('filtered-widget-progress-fade');
} else {
this._progressBarElement.classList.add('hidden');
}
this._refreshListWithCurrentResult();
}
}
/**
* @param {!Array<number>} bestItems
* @param {!Array<number>} overflowItems
* @param {!Array<number>} filteredItems
*/
_refreshList(bestItems, overflowItems, filteredItems) {
delete this._refreshListWithCurrentResult;
filteredItems = [].concat(bestItems, overflowItems, filteredItems);
this._updateNotFoundMessage(!!filteredItems.length);
const oldHeight = this._list.element.offsetHeight;
this._items.replaceAll(filteredItems);
if (filteredItems.length) {
this._list.selectItem(filteredItems[0]);
}
if (this._list.element.offsetHeight !== oldHeight) {
this._list.viewportResized();
}
this._itemsFilteredForTest();
}
/**
* @param {boolean} hasItems
*/
_updateNotFoundMessage(hasItems) {
this._list.element.classList.toggle('hidden', !hasItems);
this._notFoundElement.classList.toggle('hidden', hasItems);
if (!hasItems) {
this._notFoundElement.textContent = this._provider.notFoundText(this._cleanValue());
UI.ARIAUtils.alert(this._notFoundElement.textContent, this._notFoundElement);
}
}
_onInput() {
this._queryChanged();
this._scheduleFilter();
}
_queryChanged() {
if (this._queryChangedCallback) {
this._queryChangedCallback(this._value());
}
if (this._provider) {
this._provider.queryChanged(this._cleanValue());
}
}
/**
* @override
* @param {?Element} fromElement
* @param {?Element} toElement
* @return {boolean}
*/
updateSelectedItemARIA(fromElement, toElement) {
return false;
}
/**
* @param {!Event} event
*/
_onKeyDown(event) {
let handled = false;
switch (event.key) {
case 'Enter':
this._onEnter(event);
return;
case 'Tab':
handled = this._tabKeyPressed();
break;
case 'ArrowUp':
handled = this._list.selectPreviousItem(true, false);
break;
case 'ArrowDown':
handled = this._list.selectNextItem(true, false);
break;
case 'PageUp':
handled = this._list.selectItemPreviousPage(false);
break;
case 'PageDown':
handled = this._list.selectItemNextPage(false);
break;
}
if (handled) {
event.consume(true);
}
}
_scheduleFilter() {
if (this._filterTimer) {
return;
}
this._filterTimer = setTimeout(this._filterItems.bind(this), 0);
}
/**
* @param {?number} itemIndex
*/
_selectItem(itemIndex) {
this._promptHistory.push(this._value());
if (this._promptHistory.length > 100) {
this._promptHistory.shift();
}
this._provider.selectItem(itemIndex, this._cleanValue());
}
}
/**
* @unrestricted
*/
export class Provider {
/**
* @param {function():void} refreshCallback
*/
setRefreshCallback(refreshCallback) {
this._refreshCallback = refreshCallback;
}
attach() {
}
/**
* @return {number}
*/
itemCount() {
return 0;
}
/**
* @param {number} itemIndex
* @return {string}
*/
itemKeyAt(itemIndex) {
return '';
}
/**
* @param {number} itemIndex
* @param {string} query
* @return {number}
*/
itemScoreAt(itemIndex, query) {
return 1;
}
/**
* @param {number} itemIndex
* @param {string} query
* @param {!Element} titleElement
* @param {!Element} subtitleElement
*/
renderItem(itemIndex, query, titleElement, subtitleElement) {
}
/**
* @return {boolean}
*/
renderAsTwoRows() {
return false;
}
/**
* @param {?number} itemIndex
* @param {string} promptValue
*/
selectItem(itemIndex, promptValue) {
}
refresh() {
this._refreshCallback();
}
/**
* @param {string} query
* @return {string}
*/
rewriteQuery(query) {
return query;
}
/**
* @param {string} query
*/
queryChanged(query) {
}
/**
* @param {string} query
* @return {string}
*/
notFoundText(query) {
return Common.UIString('No results found');
}
detach() {
}
}
/* Legacy exported object */
self.QuickOpen = self.QuickOpen || {};
/* Legacy exported object */
QuickOpen = QuickOpen || {};
/**
* @constructor
*/
QuickOpen.FilteredListWidget = FilteredListWidget;
/**
* @constructor
*/
QuickOpen.FilteredListWidget.Provider = Provider;