blob: 3c7f09fab220b3ff70d9f0baefebc42525b3f8ad [file] [log] [blame]
// Copyright 2020 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.
import './strings.m.js';
import './realbox_button.js';
import './realbox_match.js';
import {assert} from 'chrome://resources/js/assert.m.js';
import {skColorToRgba} from 'chrome://resources/js/color_utils.js';
import {loadTimeData} from 'chrome://resources/js/load_time_data.m.js';
import {html, PolymerElement} from 'chrome://resources/polymer/v3_0/polymer/polymer_bundled.min.js';
import {BrowserProxy} from './browser_proxy.js';
import {decodeString16} from './utils.js';
// A dropdown element that contains autocomplete matches. Provides an API for
// the embedder (i.e., <ntp-realbox>) to change the selection.
class RealboxDropdownElement extends PolymerElement {
static get is() {
return 'ntp-realbox-dropdown';
}
static get template() {
return html`{__html_template__}`;
}
static get properties() {
return {
//========================================================================
// Public properties
//========================================================================
/**
* @type {!search.mojom.AutocompleteResult}
*/
result: {
type: Object,
},
/**
* Index of the selected match.
* @type {number}
*/
selectedMatchIndex: {
type: Number,
value: -1,
notify: true,
},
/**
* @type {!newTabPage.mojom.SearchBoxTheme}
*/
theme: {
type: Object,
observer: 'onThemeChange_',
},
//========================================================================
// Private properties
//========================================================================
/**
* The list of suggestion group IDs matches belong to.
* @type {!Array<number>}
* @private
*/
groupIds_: {
type: Array,
computed: `computeGroupIds_(result)`,
},
/**
* The list of suggestion group IDs whose matches should be hidden.
* @type {!Array<number>}
* @private
*/
hiddenGroupIds_: {
type: Array,
computed: `computeHiddenGroupIds_(result)`,
},
/**
* The list of selectable match elements.
* @type {!Array<!Element>}
* @private
*/
selectableMatchElements_: {
type: Array,
value: () => [],
},
};
}
constructor() {
super();
/** @private {!newTabPage.mojom.PageCallbackRouter} */
this.callbackRouter_ = BrowserProxy.getInstance().callbackRouter;
/** @private {newTabPage.mojom.PageHandlerRemote} */
this.pageHandler_ = BrowserProxy.getInstance().handler;
/** @private {?number} */
this.autocompleteMatchImageAvailableListenerId_ = null;
}
/** @override */
connectedCallback() {
super.connectedCallback();
this.autocompleteMatchImageAvailableListenerId_ =
this.callbackRouter_.autocompleteMatchImageAvailable.addListener(
this.onAutocompleteMatchImageAvailable_.bind(this));
}
/** @override */
disconnectedCallback() {
super.disconnectedCallback();
this.callbackRouter_.removeListener(
assert(this.autocompleteMatchImageAvailableListenerId_));
}
//============================================================================
// Public methods
//============================================================================
/**
* Unselects the currently selected match, if any.
*/
unselect() {
this.selectedMatchIndex = -1;
}
/**
* Focuses the selected match, if any.
*/
focusSelected() {
if (this.$.selector.selectedItem) {
this.$.selector.selectedItem.focus();
}
}
/**
* Selects the first match.
*/
selectFirst() {
this.selectedMatchIndex = 0;
}
/**
* Selects the match at the given index.
* @param {number} index
*/
selectIndex(index) {
this.selectedMatchIndex = index;
}
/**
* Selects the previous match with respect to the currently selected one.
* Selects the last match if the first one is currently selected.
*/
selectPrevious() {
this.selectedMatchIndex = this.selectedMatchIndex - 1 >= 0 ?
this.selectedMatchIndex - 1 :
this.selectableMatchElements_.length - 1;
}
/**
* Selects the last match.
*/
selectLast() {
this.selectedMatchIndex = this.selectableMatchElements_.length - 1;
}
/**
* Selects the next match with respect to the currently selected one.
* Selects the first match if the last one is currently selected.
*/
selectNext() {
this.selectedMatchIndex =
this.selectedMatchIndex + 1 < this.selectableMatchElements_.length ?
this.selectedMatchIndex + 1 :
0;
}
//============================================================================
// Callbacks
//============================================================================
/**
* @param {number} matchIndex match index
* @param {!url.mojom.Url} url match imageUrl or destinationUrl.
* @param {string} dataUrl match image or favicon content in in base64 encoded
* Data URL format.
* @private
*/
onAutocompleteMatchImageAvailable_(matchIndex, url, dataUrl) {
if (!this.result || !this.result.matches) {
return;
}
const match = this.result.matches[matchIndex];
if (!match) {
return;
}
// Set image or favicon content of the match, if applicable.
if (match.destinationUrl.url === url.url) {
this.set(`result.matches.${matchIndex}.faviconDataUrl`, dataUrl);
} else if (match.imageUrl === url.url) {
this.set(`result.matches.${matchIndex}.imageDataUrl`, dataUrl);
}
}
/**
* @private
*/
onResultRepaint_() {
this.dispatchEvent(new CustomEvent('result-repaint', {
bubbles: true,
composed: true,
detail: window.performance.now(),
}));
}
/**
* @private
*/
onThemeChange_() {
if (!loadTimeData.getBoolean('realboxMatchOmniboxTheme')) {
return;
}
const icon = assert(this.theme.icon);
// Icon's background color in a hovered context (0x29 == .16).
// TODO(crbug.com/1041129): Share this with the Omnibox.
const iconBgHovered = {value: icon.value & 0x29ffffff};
const iconSelected = assert(this.theme.iconSelected);
// Icon's background color in a focused context (0x52 == .32).
// TODO(crbug.com/1041129): Share this with the Omnibox.
const iconBgFocused = {value: iconSelected.value & 0x52ffffff};
this.updateStyles({
'--search-box-icon-bg-focused': skColorToRgba(iconBgFocused),
'--search-box-icon-bg-hovered': skColorToRgba(iconBgHovered),
'--search-box-icon-selected': skColorToRgba(iconSelected),
'--search-box-icon': skColorToRgba(icon),
'--search-box-results-bg-hovered':
skColorToRgba(assert(this.theme.resultsBgHovered)),
'--search-box-results-bg-selected':
skColorToRgba(assert(this.theme.resultsBgSelected)),
'--search-box-results-bg': skColorToRgba(assert(this.theme.resultsBg)),
'--search-box-results-dim-selected':
skColorToRgba(assert(this.theme.resultsDimSelected)),
'--search-box-results-dim': skColorToRgba(assert(this.theme.resultsDim)),
'--search-box-results-text-selected':
skColorToRgba(assert(this.theme.resultsTextSelected)),
'--search-box-results-text':
skColorToRgba(assert(this.theme.resultsText)),
'--search-box-results-url-selected':
skColorToRgba(assert(this.theme.resultsUrlSelected)),
'--search-box-results-url': skColorToRgba(assert(this.theme.resultsUrl)),
});
}
//============================================================================
// Event handlers
//============================================================================
/**
* @private
*/
onHeaderFocusin_() {
this.dispatchEvent(new CustomEvent('header-focusin', {
bubbles: true,
composed: true,
}));
}
/**
* @param {!Event} e
* @private
*/
onHeaderClick_(e) {
const groupId = Number(e.currentTarget.dataset.id);
// Tell the backend to toggle visibility of the given suggestion group ID.
this.pageHandler_.toggleSuggestionGroupIdVisibility(groupId);
// Hide/Show matches with the given suggestion group ID.
const index = this.hiddenGroupIds_.indexOf(groupId);
if (index === -1) {
this.push('hiddenGroupIds_', groupId);
} else {
this.splice('hiddenGroupIds_', index, 1);
}
}
/**
* @param {!Event} e
* @private
*/
onToggleButtonKeydown_(e) {
if (e.key !== 'Enter' && e.key !== ' ') {
return;
}
// Simulate a click so that it gets handled by |onHeaderClick_|.
e.target.click();
e.preventDefault(); // Prevents default browser action.
}
//============================================================================
// Helpers
//============================================================================
/**
* @returns {number} Index of the match in the autocomplete result. Passed to
* the match so it knows abut its position in the list of matches.
* @private
*/
matchIndex_(match) {
if (!this.result || !this.result.matches) {
return -1;
}
return this.result.matches.indexOf(match);
}
/**
* @returns {!Array<number>}
* @private
*/
computeGroupIds_() {
if (!this.result || !this.result.matches) {
return [];
}
// Extract the suggestion group IDs from autocomplete matches and return the
// unique IDs while preserving the order. Autocomplete matches are the
// ultimate source of truth for suggestion groups IDs matches belong to.
return [...new Set(
this.result.matches.map(match => match.suggestionGroupId))];
}
/**
* @returns {!Array<number>}
* @private
*/
computeHiddenGroupIds_() {
if (!this.result) {
return [];
}
return Object.keys(this.result.suggestionGroupsMap)
.map(groupId => Number(groupId))
.filter((groupId => {
return this.result.suggestionGroupsMap[groupId].hidden;
}).bind(this));
}
/**
* @param {number} groupId
* @returns {!function(!search.mojom.AutocompleteMatch):boolean} The filter
* function to filter matches that belong to the given suggestion group
* ID.
* @private
*/
computeMatchBelongsToGroup_(groupId) {
return (match) => {
return match.suggestionGroupId === groupId;
};
}
/**
* @param {number} groupId
* @returns {boolean} Whether the given suggestion group ID has a header.
* @private
*/
groupHasHeader_(groupId) {
return !!this.headerForGroup_(groupId);
}
/**
* @param {number} groupId
* @returns {boolean} Whether matches with the given suggestion group ID
* should be hidden.
* @private
*/
groupIsHidden_(groupId) {
return this.hiddenGroupIds_.indexOf(groupId) !== -1;
}
/**
* @param {number} groupId
* @returns {string} The header for the given suggestion group ID.
* @private
* @suppress {checkTypes}
*/
headerForGroup_(groupId) {
return (this.result && this.result.suggestionGroupsMap &&
this.result.suggestionGroupsMap[groupId]) ?
decodeString16(this.result.suggestionGroupsMap[groupId].header) :
'';
}
/**
* @param {number} groupId
* @returns {string} Tooltip for suggestion group show/hide toggle button.
* @private
*/
toggleButtonTitleForGroup_(groupId) {
if (!this.groupHasHeader_(groupId)) {
return '';
}
return loadTimeData.getString(
this.groupIsHidden_(groupId) ? 'showSuggestions' : 'hideSuggestions');
}
/**
* @param {number} groupId
* @returns {string} A11y label for suggestion group show/hide toggle button.
* @private
*/
toggleButtonA11yLabelForGroup_(groupId) {
if (!this.groupHasHeader_(groupId)) {
return '';
}
return loadTimeData.substituteString(
loadTimeData.getString(
this.groupIsHidden_(groupId) ? 'showSection' : 'hideSection'),
this.headerForGroup_(groupId));
}
}
customElements.define(RealboxDropdownElement.is, RealboxDropdownElement);