blob: 2ed8f50d0dbaf7028591ded3b04d35f830407cf6 [file] [log] [blame]
/*
* Copyright (C) 2013 Google Inc. All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions are
* met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
* * Redistributions in binary form must reproduce the above
* copyright notice, this list of conditions and the following disclaimer
* in the documentation and/or other materials provided with the
* distribution.
* * Neither the name of Google Inc. nor the names of its
* contributors may be used to endorse or promote products derived from
* this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*/
/**
* @interface
*/
export class SuggestBoxDelegate {
/**
* @param {?UI.SuggestBox.Suggestion} suggestion
* @param {boolean=} isIntermediateSuggestion
*/
applySuggestion(suggestion, isIntermediateSuggestion) {
}
/**
* acceptSuggestion will be always called after call to applySuggestion with isIntermediateSuggestion being equal to false.
*/
acceptSuggestion() {
}
}
/**
* @implements {UI.ListDelegate}
*/
export default class SuggestBox {
/**
* @param {!SuggestBoxDelegate} suggestBoxDelegate
* @param {number=} maxItemsHeight
*/
constructor(suggestBoxDelegate, maxItemsHeight) {
this._suggestBoxDelegate = suggestBoxDelegate;
this._maxItemsHeight = maxItemsHeight;
this._rowHeight = 17;
this._userEnteredText = '';
this._defaultSelectionIsDimmed = false;
/** @type {?UI.SuggestBox.Suggestion} */
this._onlyCompletion = null;
/** @type {!UI.ListModel<!UI.SuggestBox.Suggestion>} */
this._items = new UI.ListModel();
/** @type {!UI.ListControl<!UI.SuggestBox.Suggestion>} */
this._list = new UI.ListControl(this._items, this, UI.ListMode.EqualHeightItems);
this._element = this._list.element;
this._element.classList.add('suggest-box');
this._element.addEventListener('mousedown', event => event.preventDefault(), true);
this._element.addEventListener('click', this._onClick.bind(this), false);
this._glassPane = new UI.GlassPane();
this._glassPane.setAnchorBehavior(UI.GlassPane.AnchorBehavior.PreferBottom);
this._glassPane.setOutsideClickCallback(this.hide.bind(this));
const shadowRoot = UI.createShadowRootWithCoreStyles(this._glassPane.contentElement, 'ui/suggestBox.css');
shadowRoot.appendChild(this._element);
}
/**
* @return {boolean}
*/
visible() {
return this._glassPane.isShowing();
}
/**
* @param {!AnchorBox} anchorBox
*/
setPosition(anchorBox) {
this._glassPane.setContentAnchorBox(anchorBox);
}
/**
* @param {!UI.GlassPane.AnchorBehavior} behavior
*/
setAnchorBehavior(behavior) {
this._glassPane.setAnchorBehavior(behavior);
}
/**
* @param {!UI.SuggestBox.Suggestions} items
*/
_updateMaxSize(items) {
const maxWidth = this._maxWidth(items);
const length = this._maxItemsHeight ? Math.min(this._maxItemsHeight, items.length) : items.length;
const maxHeight = length * this._rowHeight;
this._glassPane.setMaxContentSize(new UI.Size(maxWidth, maxHeight));
}
/**
* @param {!UI.SuggestBox.Suggestions} items
* @return {number}
*/
_maxWidth(items) {
const kMaxWidth = 300;
if (!items.length) {
return kMaxWidth;
}
let maxItem;
let maxLength = -Infinity;
for (let i = 0; i < items.length; i++) {
const length = (items[i].title || items[i].text).length + (items[i].subtitle || '').length;
if (length > maxLength) {
maxLength = length;
maxItem = items[i];
}
}
const element = this.createElementForItem(/** @type {!UI.SuggestBox.Suggestion} */ (maxItem));
const preferredWidth =
UI.measurePreferredSize(element, this._element).width + UI.measuredScrollbarWidth(this._element.ownerDocument);
return Math.min(kMaxWidth, preferredWidth);
}
/**
* @suppressGlobalPropertiesCheck
*/
_show() {
if (this.visible()) {
return;
}
// TODO(dgozman): take document as a parameter.
this._glassPane.show(document);
this._rowHeight =
UI.measurePreferredSize(this.createElementForItem({text: '1', subtitle: '12'}), this._element).height;
}
hide() {
if (!this.visible()) {
return;
}
this._glassPane.hide();
}
/**
* @param {boolean=} isIntermediateSuggestion
* @return {boolean}
*/
_applySuggestion(isIntermediateSuggestion) {
if (this._onlyCompletion) {
UI.ARIAUtils.alert(ls`${this._onlyCompletion.text}, suggestion`, this._element);
this._suggestBoxDelegate.applySuggestion(this._onlyCompletion, isIntermediateSuggestion);
return true;
}
const suggestion = this._list.selectedItem();
if (suggestion && suggestion.text) {
UI.ARIAUtils.alert(ls`${suggestion.title || suggestion.text}, suggestion`, this._element);
}
this._suggestBoxDelegate.applySuggestion(suggestion, isIntermediateSuggestion);
return this.visible() && !!suggestion;
}
/**
* @return {boolean}
*/
acceptSuggestion() {
const result = this._applySuggestion();
this.hide();
if (!result) {
return false;
}
this._suggestBoxDelegate.acceptSuggestion();
return true;
}
/**
* @override
* @param {!UI.SuggestBox.Suggestion} item
* @return {!Element}
*/
createElementForItem(item) {
const query = this._userEnteredText;
const element = createElementWithClass('div', 'suggest-box-content-item source-code');
if (item.iconType) {
const icon = UI.Icon.create(item.iconType, 'suggestion-icon');
element.appendChild(icon);
}
if (item.isSecondary) {
element.classList.add('secondary');
}
element.tabIndex = -1;
const maxTextLength = 50 + query.length;
const displayText = (item.title || item.text).trim().trimEndWithMaxLength(maxTextLength).replace(/\n/g, '\u21B5');
const titleElement = element.createChild('span', 'suggestion-title');
const index = displayText.toLowerCase().indexOf(query.toLowerCase());
if (index > 0) {
titleElement.createChild('span').textContent = displayText.substring(0, index);
}
if (index > -1) {
titleElement.createChild('span', 'query').textContent = displayText.substring(index, index + query.length);
}
titleElement.createChild('span').textContent = displayText.substring(index > -1 ? index + query.length : 0);
titleElement.createChild('span', 'spacer');
if (item.subtitleRenderer) {
const subtitleElement = item.subtitleRenderer.call(null);
subtitleElement.classList.add('suggestion-subtitle');
element.appendChild(subtitleElement);
} else if (item.subtitle) {
const subtitleElement = element.createChild('span', 'suggestion-subtitle');
subtitleElement.textContent = item.subtitle.trimEndWithMaxLength(maxTextLength - displayText.length);
}
return element;
}
/**
* @override
* @param {!UI.SuggestBox.Suggestion} item
* @return {number}
*/
heightForItem(item) {
return this._rowHeight;
}
/**
* @override
* @param {!UI.SuggestBox.Suggestion} item
* @return {boolean}
*/
isItemSelectable(item) {
return true;
}
/**
* @override
* @param {?UI.SuggestBox.Suggestion} from
* @param {?UI.SuggestBox.Suggestion} to
* @param {?Element} fromElement
* @param {?Element} toElement
*/
selectedItemChanged(from, to, fromElement, toElement) {
if (fromElement) {
fromElement.classList.remove('selected', 'force-white-icons');
}
if (toElement) {
toElement.classList.add('selected');
toElement.classList.add('force-white-icons');
}
this._applySuggestion(true);
}
/**
* @override
* @param {?Element} fromElement
* @param {?Element} toElement
* @return {boolean}
*/
updateSelectedItemARIA(fromElement, toElement) {
return false;
}
/**
* @param {!Event} event
*/
_onClick(event) {
const item = this._list.itemForNode(/** @type {?Node} */ (event.target));
if (!item) {
return;
}
this._list.selectItem(item);
this.acceptSuggestion();
event.consume(true);
}
/**
* @param {!UI.SuggestBox.Suggestions} completions
* @param {?UI.SuggestBox.Suggestion} highestPriorityItem
* @param {boolean} canShowForSingleItem
* @param {string} userEnteredText
* @return {boolean}
*/
_canShowBox(completions, highestPriorityItem, canShowForSingleItem, userEnteredText) {
if (!completions || !completions.length) {
return false;
}
if (completions.length > 1) {
return true;
}
if (!highestPriorityItem || highestPriorityItem.isSecondary ||
!highestPriorityItem.text.startsWith(userEnteredText)) {
return true;
}
// Do not show a single suggestion if it is the same as user-entered query, even if allowed to show single-item suggest boxes.
return canShowForSingleItem && highestPriorityItem.text !== userEnteredText;
}
/**
* @param {!AnchorBox} anchorBox
* @param {!UI.SuggestBox.Suggestions} completions
* @param {boolean} selectHighestPriority
* @param {boolean} canShowForSingleItem
* @param {string} userEnteredText
*/
updateSuggestions(anchorBox, completions, selectHighestPriority, canShowForSingleItem, userEnteredText) {
this._onlyCompletion = null;
const highestPriorityItem =
selectHighestPriority ? completions.reduce((a, b) => (a.priority || 0) >= (b.priority || 0) ? a : b) : null;
if (this._canShowBox(completions, highestPriorityItem, canShowForSingleItem, userEnteredText)) {
this._userEnteredText = userEnteredText;
this._show();
this._updateMaxSize(completions);
this._glassPane.setContentAnchorBox(anchorBox);
this._list.invalidateItemHeight();
this._items.replaceAll(completions);
if (highestPriorityItem && !highestPriorityItem.isSecondary) {
this._list.selectItem(highestPriorityItem, true);
} else {
this._list.selectItem(null);
}
} else {
if (completions.length === 1) {
this._onlyCompletion = completions[0];
this._applySuggestion(true);
}
this.hide();
}
}
/**
* @param {!KeyboardEvent} event
* @return {boolean}
*/
keyPressed(event) {
switch (event.key) {
case 'Enter':
return this.enterKeyPressed();
case 'ArrowUp':
return this._list.selectPreviousItem(true, false);
case 'ArrowDown':
return this._list.selectNextItem(true, false);
case 'PageUp':
return this._list.selectItemPreviousPage(false);
case 'PageDown':
return this._list.selectItemNextPage(false);
}
return false;
}
/**
* @return {boolean}
*/
enterKeyPressed() {
const hasSelectedItem = !!this._list.selectedItem() || !!this._onlyCompletion;
this.acceptSuggestion();
// Report the event as non-handled if there is no selected item,
// to commit the input or handle it otherwise.
return hasSelectedItem;
}
}
/* Legacy exported object*/
self.UI = self.UI || {};
/* Legacy exported object*/
UI = UI || {};
/** @constructor */
UI.SuggestBox = SuggestBox;
/** @interface */
UI.SuggestBoxDelegate = SuggestBoxDelegate;
/**
* @typedef {{
* text: string,
* title: (string|undefined),
* subtitle: (string|undefined),
* iconType: (string|undefined),
* priority: (number|undefined),
* isSecondary: (boolean|undefined),
* subtitleRenderer: (function():!Element|undefined),
* selectionRange: ({startColumn: number, endColumn: number}|undefined),
* hideGhostText: (boolean|undefined)
* }}
*/
UI.SuggestBox.Suggestion;
/**
* @typedef {!Array<!UI.SuggestBox.Suggestion>}
*/
UI.SuggestBox.Suggestions;